dsh-drawai 0.3.0 → 0.3.2

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/lib/index.js CHANGED
@@ -16,9 +16,13 @@
16
16
 
17
17
  import { defineTool } from '@deepseek-ai/dsh-tools'
18
18
  import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises'
19
- import { existsSync, rmSync, writeFileSync, appendFileSync, renameSync } from 'node:fs'
20
- import { randomUUID } from 'node:crypto'
21
- import { isAbsolute, resolve as resolvePath, join as joinPath, dirname as dirnamePath, basename as basenamePath } from 'node:path'
19
+ import { existsSync, rmSync, writeFileSync, appendFileSync, renameSync, realpathSync } from 'node:fs'
20
+ import { randomUUID, createHash } from 'node:crypto'
21
+ import { createDocumentCoordinator, createMediaWorkSlots } from './task-coordinator.js'
22
+ import { createDrawingTaskManager } from './drawing-tasks.js'
23
+ import { applyDrawingResult } from './drawing-results.js'
24
+ import { createTaskPatchJournal, applyTaskPatches } from './task-patches.js'
25
+ import { isAbsolute, resolve as resolvePath, join as joinPath, dirname as dirnamePath, basename as basenamePath, relative as relativePath } from 'node:path'
22
26
  import { createVideoTracePool } from './video-trace-pool.js'
23
27
  import { videoTraceOptions, exactVideoFrameKey } from './video-trace-reuse.js'
24
28
  import { createPerformanceLog } from './performance-log.js'
@@ -31,7 +35,6 @@ import {
31
35
  resolveTraceOptions,
32
36
  summarizeTraceForAi,
33
37
  toLosslessJson,
34
- traceRasterToSvg,
35
38
  traceRasterForToolbar,
36
39
  } from './trace-image.js'
37
40
  import {
@@ -962,7 +965,7 @@ async function applyOps(doc, ops, out, opctx) {
962
965
  } catch (error) {
963
966
  throw new Error(where + ': ' + messageOf(error))
964
967
  }
965
- const traced = traceRasterToSvg(tracedFrom, resolved.options)
968
+ const traced = await opctx.traceRaster(tracedFrom, resolved.options)
966
969
  const metrics = traced.metrics
967
970
  if (traced.svg.length > SVG_MARKUP_LIMIT) {
968
971
  throw new Error(
@@ -1874,8 +1877,8 @@ async function applyOps(doc, ops, out, opctx) {
1874
1877
  stills: stills.length,
1875
1878
  plan:
1876
1879
  plan !== null
1877
- ? Object.assign({}, plan, { note: '已把抽帧计划挂给画布:它在浏览器里解码视频写 stills/' })
1878
- : { every: every, max: max, note: '带上 seconds/fps/width 下发抽帧计划,浏览器会写入 stills/' },
1880
+ ? Object.assign({}, plan, { note: '已交给宿主后台抽帧,完成后写入 stills/' })
1881
+ : { every: every, max: max, note: '带上 seconds/fps/width 提交宿主抽帧任务' },
1879
1882
  })
1880
1883
  // 计划由 applyOps 收集、由外层挂到画布轮询上(见 diagram_apply 尾部)。
1881
1884
  if (plan !== null) out.extract = Object.assign({ clip: resolved.meta.clip }, plan)
@@ -1905,7 +1908,7 @@ async function applyOps(doc, ops, out, opctx) {
1905
1908
  throw new Error(where + ': ' + messageOf(error) + ' —— 先 extractFrames 下发抽帧计划并等待 stills/ 写出')
1906
1909
  }
1907
1910
  const raster = await decodeRaster(png)
1908
- const traced = traceRasterToSvg(raster, op)
1911
+ const traced = await opctx.traceRaster(raster, op)
1909
1912
  if (traced.svg.length > MAX_FRAME_SVG_CHARS) {
1910
1913
  throw new Error(where + ': traced svg too long (' + traced.svg.length + ' > ' + MAX_FRAME_SVG_CHARS + ')')
1911
1914
  }
@@ -2665,6 +2668,7 @@ async function listCanvases(root, cwd, dir) {
2665
2668
 
2666
2669
 
2667
2670
  export function apply(ctx) {
2671
+ const documents = createDocumentCoordinator(), mediaSlots = createMediaWorkSlots(2), taskPatches = createTaskPatchJournal()
2668
2672
  function sessionOf(sessionId) {
2669
2673
  try {
2670
2674
  if (typeof sessionId !== 'string' || sessionId.length === 0) return undefined
@@ -2755,6 +2759,8 @@ export function apply(ctx) {
2755
2759
  pageCount: parsed.pageCount,
2756
2760
  pageIndex: parsed.pageIndex,
2757
2761
  pageNames: parsed.pageNames,
2762
+ pageId: parsed.pageId,
2763
+ pageIds: parsed.pageIds,
2758
2764
  }
2759
2765
  }
2760
2766
 
@@ -3664,6 +3670,7 @@ export function apply(ctx) {
3664
3670
  const out = {}
3665
3671
  const opctx = {
3666
3672
  drawioPath: loaded.absolute,
3673
+ traceRaster: (raster, options) => videoTracePool.runRaster(raster, { ...options, mode: 'raw-image' }),
3667
3674
  ensureClips: [],
3668
3675
  /**
3669
3676
  * traceImage 的图片读取:走**同一个工作区解析器**(相对路径按会话根解析),
@@ -3732,7 +3739,7 @@ export function apply(ctx) {
3732
3739
  exportRequests.set(sessionId, { requestId: requestId, absolute: loaded.absolute, format: out.export.format, name: out.export.name })
3733
3740
  exportResults.delete(sessionId)
3734
3741
  }
3735
- // 抽帧计划:同样挂成请求 —— 真正解码视频的是画布那一半(浏览器)。
3742
+ // The host owns extraction; the browser receives status only.
3736
3743
  if (out.extract !== undefined && out.extract !== null) {
3737
3744
  const requestId = 'm' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
3738
3745
  extractRequests.set(sessionId, {
@@ -3744,6 +3751,7 @@ export function apply(ctx) {
3744
3751
  fps: out.extract.fps,
3745
3752
  width: out.extract.width,
3746
3753
  max: out.extract.max,
3754
+ host: true,
3747
3755
  })
3748
3756
  extractResults.delete(sessionId)
3749
3757
  }
@@ -3755,6 +3763,7 @@ export function apply(ctx) {
3755
3763
  Array.isArray(out.changed) && out.changed.length > 0 &&
3756
3764
  ops.some((op) => op !== null && typeof op === 'object' && (op.op === 'setMediaMeta' || op.op === 'importMedia' || op.op === 'setFrame' || op.op === 'traceFrame'))
3757
3765
  if (noDocEdit && mediaChangedDoc === false) {
3766
+ if (out.extract) await startHostExtraction(sessionId, loaded, out.extract)
3758
3767
  // toLosslessJson:宿主对工具输出做 isJsonValue,自有属性值为 undefined / -0 会整次打回
3759
3768
  // (表现为 `returned invalid output: value is not lossless JSON`)。
3760
3769
  return toLosslessJson({
@@ -3809,6 +3818,7 @@ export function apply(ctx) {
3809
3818
  if (revertDepth(snapshotKey(sessionId, loaded.absolute)) > 0) {
3810
3819
  notes.push('↩ 这次改动可以退回(还能退 ' + revertDepth(snapshotKey(sessionId, loaded.absolute)) + ' 步):界面上「编辑 → 撤销 AI 改动」')
3811
3820
  }
3821
+ if (out.extract) await startHostExtraction(sessionId, loaded, out.extract)
3812
3822
  return toLosslessJson({
3813
3823
  path: loaded.absolute,
3814
3824
  revision: doc.revision,
@@ -4070,7 +4080,7 @@ function sanitizeNewName(raw) {
4070
4080
  }
4071
4081
  const done = extractResults.get(sessionId)
4072
4082
  if (done === undefined || done.absolute !== absolute) return undefined
4073
- if (done.error.length > 0) return { status: 'failed', clip: done.clip, written: done.written, error: done.error }
4083
+ if (done.error?.length > 0) return { status: 'failed', clip: done.clip, written: done.written, error: done.error }
4074
4084
  return { status: 'done', clip: done.clip, written: done.written }
4075
4085
  }
4076
4086
 
@@ -4155,7 +4165,8 @@ function sanitizeNewName(raw) {
4155
4165
  // (format v4 message requires a producer-owned source kind)。
4156
4166
  // 目标形是 `{ kind: 'plugin:<名>' }`(V3→V4 迁移对旧 wrapper 也写成这样),
4157
4167
  // 对 user/message 注入在 V3/V4 都可过;不带 form —— 它只是"环境事实"。
4158
- agent.inject({ role: 'user', content: [{ type: 'text', text: text }], source: { kind: 'plugin:' + name } })
4168
+ // 消息 id 会落到 user/message 的 data.id;缺失会导致持久化后的会话读取校验失败。
4169
+ agent.inject({ id: randomUUID(), role: 'user', content: [{ type: 'text', text: text }], source: { kind: 'plugin:' + name } })
4159
4170
  } catch (error) {
4160
4171
  // 注入失败不该影响画布本身:工具那条回退链还在。
4161
4172
  }
@@ -4181,8 +4192,136 @@ function sanitizeNewName(raw) {
4181
4192
  // 临时复刻包只由持有本次令牌的会话管理,取消不触及已有媒体。
4182
4193
  const videoTraceJobs = new Map()
4183
4194
  const videoUploadJobs = new Map()
4184
- const videoExports = createVideoExportManager()
4195
+ const videoExports = createVideoExportManager({ slots: mediaSlots })
4185
4196
  const videoTracePool = createVideoTracePool()
4197
+ const drawingTasks = createDrawingTaskManager({ pool: videoTracePool, slots: mediaSlots,
4198
+ commit: commitDrawingTask, undo: undoDrawingTask,
4199
+ publishExtraction: (job, action) => documents.run(job.scope.drawioAbs, async () => {
4200
+ const loaded = await loadTaskDocument(job)
4201
+ const source = loaded.doc.nodes.find((node) => node.id === job.source.id)
4202
+ if (videoMetaFromData(source?.data)?.clip !== job.source.clip) throw new Error('抽帧来源已更改,结果已保留在任务目录')
4203
+ await checkVideoInput(job)
4204
+ return action()
4205
+ }),
4206
+ extracted: (job, output) => {
4207
+ extractRequests.delete(job.scope.sessionId)
4208
+ extractResults.set(job.scope.sessionId, { absolute: job.scope.drawioAbs, clip: job.source.clip, written: output.written, taskId: job.id })
4209
+ },
4210
+ failed: (job) => {
4211
+ if (job.kind === 'extract') {
4212
+ extractRequests.delete(job.scope.sessionId)
4213
+ extractResults.set(job.scope.sessionId, { absolute: job.scope.drawioAbs, clip: job.source.clip, written: job.done, error: job.error || '抽帧已取消' })
4214
+ }
4215
+ },
4216
+ })
4217
+ const signature = (value) => createHash('sha256').update(value).digest('hex')
4218
+ const stableValue = (value) => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
4219
+ ? Object.fromEntries(Object.keys(item).sort().map((key) => [key, item[key]])) : item)
4220
+ async function loadTaskDocument(job) {
4221
+ let loaded = await loadDoc(job.scope.drawioAbs, job.scope.sessionId, job.scope.page)
4222
+ if (!loaded.exists) throw new Error('原画布已删除,结果已保留')
4223
+ if (job.scope.pageId) {
4224
+ const page = loaded.pageIds.indexOf(job.scope.pageId)
4225
+ if (page < 0 || loaded.pageIds.filter((id) => id === job.scope.pageId).length !== 1) throw new Error('原页面已删除或身份冲突,结果已保留')
4226
+ if (page !== loaded.pageIndex) loaded = await loadDoc(job.scope.drawioAbs, job.scope.sessionId, page)
4227
+ } else if (JSON.stringify(loaded.pageNames) !== JSON.stringify(job.scope.pageNames)) throw new Error('页面身份已改变,结果已保留')
4228
+ return loaded
4229
+ }
4230
+ async function writeTaskDocument(job, loaded, node, removed = []) {
4231
+ const policy = policyFor(job.scope.sessionId)
4232
+ if (policy?.mode === 'read-only') throw new Error('当前会话为只读,绘制结果已保留')
4233
+ const rendered = renderDocText(loaded.text, loaded.doc, { page: loaded.pageIndex })
4234
+ // The coordinator serializes plugin writers; a fresh check also detects
4235
+ // external edits which happened while an asynchronous source check ran.
4236
+ if (contentHash(await ctx.fs.readText(loaded.target)) !== contentHash(loaded.text)) throw new Error('文件已被其他编辑器修改,请重试加入结果')
4237
+ const savedNode = node ? normalizeDoc(parseMxfile(rendered.text, { page: loaded.pageIndex }).doc).nodes.find((item) => item.id === node.id) : null
4238
+ if (savedNode) drawingTasks.recordCommit(job, savedNode)
4239
+ await ctx.fs.writeText(loaded.target, rendered.text, undefined, undefined, policy)
4240
+ taskPatches.record(loaded.absolute, contentHash(loaded.text), rendered.revision, loaded.pageIndex, savedNode ? [savedNode] : [], removed, removed.length && job.createdNode ? [job.createdNode] : [])
4241
+ return { nodeId: savedNode?.id, node: savedNode, page: loaded.pageIndex }
4242
+ }
4243
+ async function commitDrawingTask(job, output) {
4244
+ return documents.run(job.scope.drawioAbs, async () => {
4245
+ const loaded = await loadTaskDocument(job)
4246
+ const existing = loaded.doc.nodes.find((node) => node.data?.drawaiTaskId === job.id)
4247
+ if (existing) return { nodeId: existing.id, node: job.createdNode, page: loaded.pageIndex }
4248
+ const source = loaded.doc.nodes.find((node) => node.id === job.source.id)
4249
+ if (!source) throw new Error('来源节点已删除,结果已保留')
4250
+ const image = job.kind === 'image' ? imageValueFromStyle(source.style || '') : ''
4251
+ if (job.kind === 'image' && signature(image) !== job.source.signature) throw new Error('原图已更改,结果已保留')
4252
+ if (job.kind === 'video') {
4253
+ await checkVideoInput(job)
4254
+ }
4255
+ const node = applyDrawingResult(loaded.doc, { ...job, source: { ...job.source, image } }, output)
4256
+ loaded.doc.meta = { ...loaded.doc.meta, pinned: true }
4257
+ return writeTaskDocument(job, loaded, node)
4258
+ })
4259
+ }
4260
+ async function checkVideoInput(job) {
4261
+ const dir = clipDirFor(job.scope.drawioAbs, job.source.clip), original = findSourceFile(dir, readManifest(dir))
4262
+ if (!original) throw new Error('原视频已删除,结果已保留')
4263
+ const rel = relativePath(realpathSync(dir), realpathSync(original.abs))
4264
+ if (isAbsolute(rel) || rel.startsWith('..') || signature(await readFile(original.abs)) !== job.inputHash) throw new Error('原视频内容或路径已更改,结果已保留')
4265
+ }
4266
+ async function undoDrawingTask(job) {
4267
+ return documents.run(job.scope.drawioAbs, async () => {
4268
+ const loaded = await loadTaskDocument(job)
4269
+ const node = loaded.doc.nodes.find((item) => item.data?.drawaiTaskId === job.id)
4270
+ if (!node) return
4271
+ if (stableValue(node) !== stableValue(job.createdNode)) throw new Error('生成节点已被修改,请在画布中撤销或删除,避免丢失后续编辑')
4272
+ if (loaded.doc.edges.some((edge) => edge.from === node.id || edge.to === node.id)) throw new Error('生成节点已连接新连线,请在画布中处理')
4273
+ loaded.doc.nodes = loaded.doc.nodes.filter((item) => item.id !== node.id)
4274
+ loaded.doc.drawOrder = (loaded.doc.drawOrder || []).filter((id) => id !== node.id)
4275
+ await writeTaskDocument(job, loaded, null, [node.id])
4276
+ })
4277
+ }
4278
+ async function enqueueDrawing(scope, body) {
4279
+ return documents.run(scope.drawioAbs, async () => {
4280
+ if (policyFor(scope.sessionId)?.mode === 'read-only') throw new Error('当前会话为只读,无法创建绘制任务')
4281
+ if (!Number.isInteger(body.page ?? 0) || (body.page ?? 0) < 0) throw new Error('页面位置无效')
4282
+ const loaded = await loadDoc(scope.drawioAbs, scope.sessionId, body.page || 0)
4283
+ if (loaded.pageIndex !== (body.page || 0)) throw new Error('页面已不存在')
4284
+ if (!loaded.exists) throw new Error('请先创建画布')
4285
+ if (body.revision && body.revision !== loaded.doc.revision) throw new Error('画布版本已更新,请等待同步后重试')
4286
+ const kind = body.kind
4287
+ if (!['image', 'video', 'extract'].includes(kind)) throw new Error('未知绘制类型')
4288
+ const source = loaded.doc.nodes.find((node) => node.id === body.sourceId)
4289
+ if (!source) throw new Error('来源节点不存在')
4290
+ const options = { level: 'medium', preset: 'default', colors: 12, removeBg: false, ...(body.options || {}) }
4291
+ if (!['low', 'medium', 'high'].includes(options.level) || !TRACE_PRESET_NAMES.includes(options.preset) ||
4292
+ !Number.isInteger(Number(options.colors)) || options.colors < 2 || options.colors > 24 || typeof options.removeBg !== 'boolean') throw new Error('绘制参数无效')
4293
+ const taskScope = { ...scope, page: loaded.pageIndex, pageId: loaded.pageId, pageNames: loaded.pageNames,
4294
+ pageName: loaded.pageNames[loaded.pageIndex] }
4295
+ let input, taskSource
4296
+ if (kind === 'image') {
4297
+ const image = imageValueFromStyle(source.style || '')
4298
+ if (!isRasterDataUri(image)) throw new Error('请选择位图来源')
4299
+ input = Buffer.from(image.slice(image.indexOf(',') + 1), 'base64')
4300
+ taskSource = { id: source.id, signature: signature(image) }
4301
+ } else {
4302
+ const meta = videoMetaFromData(source.data)
4303
+ if (meta?.kind !== 'raster') throw new Error('请选择原视频来源')
4304
+ const dir = clipDirFor(scope.drawioAbs, meta.clip), original = findSourceFile(dir, readManifest(dir))
4305
+ if (!original) throw new Error('原视频文件缺失')
4306
+ const mediaRel = relativePath(realpathSync(scope.workspaceRoot), realpathSync(dir))
4307
+ if (isAbsolute(mediaRel) || mediaRel.startsWith('..')) throw new Error('视频媒体目录超出工作区')
4308
+ const file = realpathSync(original.abs), rel = relativePath(realpathSync(dir), file)
4309
+ if (isAbsolute(rel) || rel.startsWith('..')) throw new Error('原视频路径超出媒体包')
4310
+ input = file
4311
+ taskSource = { id: source.id, clip: meta.clip, extension: /\.[a-z0-9]+$/i.exec(file)?.[0] || '.mp4', signature: meta.clip + ':' + signature(await readFile(file)) }
4312
+ }
4313
+ return drawingTasks.enqueue(taskScope, { kind, options, source: taskSource, label: source.label || (kind === 'image' ? '图片' : '视频') + ' · ' + source.id }, input)
4314
+ })
4315
+ }
4316
+ async function startHostExtraction(sessionId, loaded, plan) {
4317
+ const current = await loadDoc(loaded.absolute, sessionId, loaded.pageIndex)
4318
+ const source = current.doc.nodes.find((node) => videoMetaFromData(node.data)?.clip === plan.clip)
4319
+ if (!source) throw new Error('抽帧来源节点已不存在')
4320
+ const job = await enqueueDrawing({ sessionId, path: loaded.absolute, drawioAbs: loaded.absolute, workspaceRoot: workspaceRootOf(sessionId) },
4321
+ { kind: 'extract', sourceId: source.id, page: current.pageIndex, options: { ...plan, range: 'segment' } })
4322
+ const pending = extractRequests.get(sessionId)
4323
+ if (pending) pending.taskId = job.id
4324
+ }
4186
4325
  const wakeVideoTrace = (job) => {
4187
4326
  const waiters = job.waiters || []
4188
4327
  job.waiters = []
@@ -4215,9 +4354,8 @@ function sanitizeNewName(raw) {
4215
4354
  job.cancelled = true
4216
4355
  wakeVideoTrace(job)
4217
4356
  }
4218
- videoTracePool.close()
4219
4357
  videoTraceJobs.clear(); videoUploadJobs.clear()
4220
- return videoExports.dispose()
4358
+ return Promise.all([drawingTasks.close(), videoExports.dispose()]).finally(() => videoTracePool.close())
4221
4359
  })
4222
4360
  async function handleSave(req, res) {
4223
4361
  if (req.method !== 'POST') {
@@ -4269,9 +4407,35 @@ function sanitizeNewName(raw) {
4269
4407
  return
4270
4408
  }
4271
4409
 
4410
+ try {
4411
+ const lockAction = body.action === undefined || ['create', 'revert', 'media-cleanup', 'media-video-export-start'].includes(body.action)
4412
+ if (lockAction && typeof (body.path || body.name) === 'string' && workspaceRootOf(body.sessionId)) {
4413
+ const target = await resolveTarget(body.path || body.name, body.sessionId)
4414
+ return await documents.run(absoluteHint(target, body.path || body.name), () => dispatchSave(body, res))
4415
+ }
4416
+ return await dispatchSave(body, res)
4417
+ } catch (error) { sendJson(res, 400, { ok: false, error: messageOf(error) }) }
4418
+ }
4419
+
4420
+ async function dispatchSave(body, res) {
4421
+
4272
4422
  const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined
4273
4423
  const rawPath = typeof body.path === 'string' ? body.path : undefined
4274
4424
 
4425
+ if (typeof body.action === 'string' && body.action.startsWith('drawing-task-')) {
4426
+ if (!sessionOf(sessionId) || !workspaceRootOf(sessionId) || !rawPath?.toLowerCase().endsWith('.drawio')) throw new Error('绘制任务需要当前工作区画布')
4427
+ const target = await resolveTarget(rawPath, sessionId), root = workspaceRootOf(sessionId)
4428
+ if (!ctx.fs.contains(await ctx.fs.resolve(root), target)) throw new Error('画布路径超出工作区')
4429
+ const scope = { sessionId, path: rawPath, drawioAbs: absoluteHint(target, rawPath), workspaceRoot: root }
4430
+ let result
4431
+ if (body.action === 'drawing-task-start') result = { job: await enqueueDrawing(scope, body) }
4432
+ else if (body.action === 'drawing-task-list') result = { jobs: drawingTasks.list(scope) }
4433
+ else if (body.action === 'drawing-task-result') result = drawingTasks.result(scope, body.taskId)
4434
+ else if (body.action === 'drawing-task-command') result = { job: await drawingTasks.command(scope, body.taskId, body.command, body.value) }
4435
+ else throw new Error('未知绘制任务操作')
4436
+ sendJson(res, 200, { ok: true, ...result }); return
4437
+ }
4438
+
4275
4439
  // 仅计算图像节点的 SVG;由客户端通过同一历史/保存通道应用结果。
4276
4440
  if (body.action === 'trace-node-image') {
4277
4441
  if (sessionId === undefined || sessionOf(sessionId) === undefined || workspaceRootOf(sessionId) === undefined) {
@@ -4449,7 +4613,7 @@ function sanitizeNewName(raw) {
4449
4613
  // AI 下发的抽帧计划同理:画布取走后用浏览器解码视频、写 stills/,完成后 POST 回 media-extract-result。
4450
4614
  const pendingExtract = extractRequests.get(sessionId)
4451
4615
  const extractForThis =
4452
- pendingExtract !== undefined && pendingExtract.absolute === loaded.absolute
4616
+ pendingExtract !== undefined && !pendingExtract.host && pendingExtract.absolute === loaded.absolute
4453
4617
  ? {
4454
4618
  requestId: pendingExtract.requestId,
4455
4619
  clip: pendingExtract.clip,
@@ -4471,6 +4635,8 @@ function sanitizeNewName(raw) {
4471
4635
  pageCount: loaded.pageCount,
4472
4636
  pageIndex: loaded.pageIndex,
4473
4637
  pageNames: loaded.pageNames,
4638
+ backgroundPatch: typeof body.revision === 'string' && body.revision !== loaded.doc.revision
4639
+ ? taskPatches.between(loaded.absolute, body.revision, loaded.doc.revision, loaded.pageIndex) : null,
4474
4640
  highlight: Array.isArray(pendingHighlight) ? pendingHighlight : [],
4475
4641
  canRevert: revertDepth(snapshotKey(sessionId, loaded.absolute)) > 0,
4476
4642
  // 用户当前的选区(客户端可选地用来做提示;AI 侧由工具读回)。
@@ -4589,10 +4755,12 @@ function sanitizeNewName(raw) {
4589
4755
  }
4590
4756
  let revertTarget
4591
4757
  let revertAbsolute
4758
+ let revertCurrent
4592
4759
  try {
4593
4760
  const loaded = await loadDoc(rawPath, sessionId)
4594
4761
  revertTarget = loaded.target
4595
4762
  revertAbsolute = loaded.absolute
4763
+ revertCurrent = loaded.text
4596
4764
  } catch (error) {
4597
4765
  sendJson(res, 400, { ok: false, error: '这个文件读不出画布:' + messageOf(error) })
4598
4766
  return
@@ -4604,6 +4772,16 @@ function sanitizeNewName(raw) {
4604
4772
  sendJson(res, 200, { ok: false, error: '这张画布没有可退回的 AI 改动了' })
4605
4773
  return
4606
4774
  }
4775
+ if (contentHash(revertCurrent) !== snapshot.revision) {
4776
+ const taskNodes = (text) => {
4777
+ const first = parseMxfile(text), nodes = []
4778
+ for (let page = 0; page < first.pageCount; page++) nodes.push(...parseMxfile(text, { page }).doc.nodes.filter((node) => node.data?.drawaiTaskId))
4779
+ return nodes
4780
+ }
4781
+ if (stableValue(taskNodes(revertCurrent)) !== stableValue(taskNodes(snapshot.text))) {
4782
+ sendJson(res, 409, { ok: false, error: 'AI 改动后已有后台生成或编辑的结果,请先在任务队列中撤销生成,避免整页回退丢失结果' }); return
4783
+ }
4784
+ }
4607
4785
  const policy = policyFor(sessionId)
4608
4786
  try {
4609
4787
  if (policy === undefined) await ctx.fs.writeText(revertTarget, snapshot.text)
@@ -4907,8 +5085,7 @@ function sanitizeNewName(raw) {
4907
5085
  let result
4908
5086
  switch (body.action) {
4909
5087
  case 'media-video-export-start': result = await videoExports.start(scope, body.format); break
4910
- case 'media-video-export-chunk': result = videoExports.chunk(scope, body); break
4911
- case 'media-video-export-frame': result = videoExports.frame(scope, body); break
5088
+ case 'media-video-export-list': result = { jobs: videoExports.list(scope) }; break
4912
5089
  case 'media-video-export-finish': result = videoExports.finish(scope, body.token); break
4913
5090
  case 'media-video-export-status': result = videoExports.status(scope, body.token); break
4914
5091
  case 'media-video-export-read': result = videoExports.read(scope, body.token, body.offset); break
@@ -5336,8 +5513,15 @@ function sanitizeNewName(raw) {
5336
5513
  return
5337
5514
  }
5338
5515
  if (body.action === 'media-cleanup') {
5339
- const doc = body.doc
5340
- const removed = cleanupUnreferencedClips(drawioAbs, doc)
5516
+ const current = await loadDoc(drawioAbs, sessionId, 0)
5517
+ const nodes = [...(body.doc?.nodes || [])]
5518
+ for (let page = 0; page < current.pageNames.length; page++) {
5519
+ const parsed = page === 0 ? current : await loadDoc(drawioAbs, sessionId, page)
5520
+ nodes.push(...parsed.doc.nodes)
5521
+ }
5522
+ const leases = new Set([...drawingTasks.leases(drawioAbs, { workspaceRoot: mediaRoot }), ...videoExports.leases(drawioAbs)])
5523
+ for (const job of videoTraceJobs.values()) if (job.drawioAbs === drawioAbs) leases.add(job.clip)
5524
+ const removed = cleanupUnreferencedClips(drawioAbs, { nodes }, leases)
5341
5525
  sendJson(res, 200, { ok: true, removed: removed })
5342
5526
  return
5343
5527
  }
@@ -5508,14 +5692,18 @@ function sanitizeNewName(raw) {
5508
5692
  sendJson(res, 409, { ok: false, error: 'already exists', path: absolute })
5509
5693
  return
5510
5694
  }
5695
+ let backgroundPatch = null
5511
5696
  if (baseRevision !== undefined && exists && contentHash(originalText) !== baseRevision) {
5512
- sendJson(res, 409, { ok: false, error: 'revision conflict', currentRevision: contentHash(originalText) })
5513
- return
5697
+ backgroundPatch = taskPatches.between(absolute, baseRevision, contentHash(originalText), Number(body.page) || 0)
5698
+ if (!backgroundPatch) {
5699
+ sendJson(res, 409, { ok: false, error: 'revision conflict', currentRevision: contentHash(originalText) }); return
5700
+ }
5514
5701
  }
5515
5702
 
5516
5703
  // 人工编辑一律打 pinned:从此这张画布是人摆过版面的,AI 的自动布局不该再冲掉它。
5517
5704
  // (空文件 → 从零生成;已有文件 → 无损写回,见 renderDocText。)
5518
5705
  const next = normalizeDoc(doc)
5706
+ if (backgroundPatch) applyTaskPatches(next, backgroundPatch, true)
5519
5707
  next.meta = Object.assign({}, next.meta, { pinned: true })
5520
5708
  let rendered
5521
5709
  try {
@@ -5544,13 +5732,18 @@ function sanitizeNewName(raw) {
5544
5732
  // 媒体复制失败不回滚画布写入:海报仍在;播放时会报媒体包缺失。
5545
5733
  }
5546
5734
  }
5547
- sendJson(res, 200, { ok: true, revision: rendered.revision, path: absolute, dropped: rendered.dropped })
5735
+ sendJson(res, 200, { ok: true, revision: rendered.revision, path: absolute, dropped: rendered.dropped, ...(backgroundPatch ? { backgroundPatch } : {}) })
5548
5736
  }
5549
5737
 
5550
5738
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: SAVE_PATH, handler: handleSave }), 'drawai: ' + SAVE_PATH)
5551
5739
 
5552
5740
  ctx.effect(() => ctx.tools.register(readTool))
5553
- ctx.effect(() => ctx.tools.register(applyTool))
5741
+ ctx.effect(() => ctx.tools.register({ ...applyTool, execute: async (args, exec) => {
5742
+ const sessionId = sessionIdOf(exec)
5743
+ const path = typeof args.path === 'string' && args.path ? args.path : focusedPathFor(sessionId) || DEFAULT_PATH
5744
+ const target = await resolveTarget(path, sessionId)
5745
+ return documents.run(absoluteHint(target, path), () => applyTool.execute(args, exec))
5746
+ } }))
5554
5747
 
5555
5748
  // 把"怎么用这张画布"注册成**内嵌技能**(见 SKILL_BODY 的注释):
5556
5749
  // 换了电脑、只装了插件(没有源码/README/tools)时,模型依然知道该怎么操作。
package/lib/media.js CHANGED
@@ -209,7 +209,9 @@ export function readManifest(clipDir) {
209
209
  export function writeManifest(clipDir, manifest) {
210
210
  mkdirSync(clipDir, { recursive: true })
211
211
  const next = syncDuration(normalizeManifest(manifest))
212
- writeFileSync(join(clipDir, MANIFEST_NAME), JSON.stringify(next, null, 2) + '\n', 'utf8')
212
+ const path = join(clipDir, MANIFEST_NAME)
213
+ writeFileSync(path + '.tmp', JSON.stringify(next, null, 2) + '\n', 'utf8')
214
+ renameSync(path + '.tmp', path)
213
215
  return next
214
216
  }
215
217
 
@@ -399,8 +401,9 @@ export function collectReferencedClips(doc) {
399
401
  return used
400
402
  }
401
403
 
402
- export function cleanupUnreferencedClips(drawioPath, doc) {
404
+ export function cleanupUnreferencedClips(drawioPath, doc, protectedClips = []) {
403
405
  const used = collectReferencedClips(doc)
406
+ for (const clip of protectedClips) used.add(clip)
404
407
  const all = listClipIds(drawioPath)
405
408
  const removed = []
406
409
  for (let i = 0; i < all.length; i += 1) {
package/lib/mxfile.js CHANGED
@@ -532,6 +532,7 @@ function analyzeMxfile(source, pageIndex) {
532
532
  pageCount: diagrams.length,
533
533
  pageIndex: idx,
534
534
  pageNames: pageNames,
535
+ pageIds: diagrams.map((item) => attr(item[1], 'id') || null),
535
536
  // 纸张尺寸(drawio 挂在 `mxGraphModel` 上的属性):AI 做布局/导出建议时要看它 ——
536
537
  // 850×1100 是 drawio 的 A4 竖版缺省,但不代表用户这份文件就是那个尺寸。
537
538
  pageSize: readPageSize(modelXml),
@@ -975,7 +976,8 @@ export function parseMxfile(text, options) {
975
976
  if (existing !== null && existing.meta !== undefined && existing.meta !== null && existing.meta.pinned === true) {
976
977
  doc.meta.pinned = true
977
978
  }
978
- return { doc: doc, notes: notes, pageCount: pageCount, pageIndex: info.pageIndex, pageNames: info.pageNames }
979
+ return { doc: doc, notes: notes, pageCount: pageCount, pageIndex: info.pageIndex, pageNames: info.pageNames,
980
+ pageId: (info.pageIds || [])[info.pageIndex] || null, pageIds: info.pageIds || [null] }
979
981
  }
980
982
 
981
983
  // ---- 单元的 XML 生成(新单元、以及从零生成一份文件时共用)--------------------
@@ -11,7 +11,7 @@ function environmentInfo() {
11
11
  if (!environment) {
12
12
  const cpu = cpus()
13
13
  let sourceSha256 = null
14
- try { sourceSha256 = createHash('sha256').update(['trace-image.js', 'raster.js', 'video-trace-worker.js', 'video-trace-pool.js', 'video-trace-reuse.js', 'video-export.js']
14
+ try { sourceSha256 = createHash('sha256').update(['trace-image.js', 'raster.js', 'video-trace-worker.js', 'video-trace-pool.js', 'video-trace-reuse.js', 'video-export.js', 'video-export-worker.js']
15
15
  .map((file) => readFileSync(new URL(file, import.meta.url))).join('')).digest('hex') } catch {}
16
16
  environment = { node: process.version, platform: process.platform, arch: process.arch, cpu: cpu[0]?.model,
17
17
  logicalCpus: cpu.length, totalMemoryBytes: totalmem(), sourceSha256 }
@@ -0,0 +1,54 @@
1
+ /* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
2
+ import { AsyncLocalStorage } from 'node:async_hooks'
3
+ import { resolve } from 'node:path'
4
+ import { realpathSync } from 'node:fs'
5
+
6
+ export function createDocumentCoordinator() {
7
+ const tails = new Map(), local = new AsyncLocalStorage()
8
+ const keyOf = (path) => {
9
+ let key = resolve(path)
10
+ try { key = realpathSync(key) } catch {}
11
+ return process.platform === 'win32' ? key.toLowerCase() : key
12
+ }
13
+ return { keyOf, async run(path, action) {
14
+ const key = keyOf(path)
15
+ if (local.getStore() === key) return action()
16
+ const previous = tails.get(key) || Promise.resolve()
17
+ let release
18
+ const gate = new Promise((resolve) => { release = resolve })
19
+ const tail = previous.catch(() => {}).then(() => gate)
20
+ tails.set(key, tail)
21
+ await previous.catch(() => {})
22
+ try { return await local.run(key, action) }
23
+ finally { release(); if (tails.get(key) === tail) tails.delete(key) }
24
+ } }
25
+ }
26
+
27
+ // A shared, abortable budget for user-level drawing and export jobs. Individual
28
+ // drawing tasks reuse the existing bounded frame Worker pool.
29
+ export function createMediaWorkSlots(limit = 2) {
30
+ let active = 0
31
+ const queue = []
32
+ function pump() {
33
+ while (active < limit && queue.length) {
34
+ const item = queue.shift()
35
+ item.signal?.removeEventListener('abort', item.abort)
36
+ if (item.signal?.aborted) { item.reject(new Error('已取消任务')); continue }
37
+ active++
38
+ let released = false
39
+ item.resolve(() => { if (!released) { released = true; active--; pump() } })
40
+ }
41
+ }
42
+ return { acquire(signal) {
43
+ return new Promise((resolve, reject) => {
44
+ const item = { signal, resolve, reject, abort: () => {
45
+ const index = queue.indexOf(item)
46
+ if (index >= 0) queue.splice(index, 1)
47
+ reject(new Error('已取消任务'))
48
+ } }
49
+ if (signal?.aborted) { reject(new Error('已取消任务')); return }
50
+ signal?.addEventListener('abort', item.abort, { once: true })
51
+ queue.push(item); pump()
52
+ })
53
+ } }
54
+ }
@@ -0,0 +1,59 @@
1
+ /* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
2
+ import { createHash } from 'node:crypto'
3
+ import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs'
4
+ import { dirname, join } from 'node:path'
5
+
6
+ // Rebase only changes known to consist of generated-node patches. Unknown
7
+ // edits remain revision conflicts, never a license to overwrite a snapshot.
8
+ export function createTaskPatchJournal() {
9
+ const cache = new Map()
10
+ const fileOf = (path) => join(dirname(path), '.drawai-cache', 'task-patches', createHash('sha256').update(path.toLowerCase()).digest('hex') + '.json')
11
+ function entries(path) {
12
+ if (!cache.has(path)) {
13
+ let values = []
14
+ try { values = JSON.parse(readFileSync(fileOf(path), 'utf8')) } catch {}
15
+ cache.set(path, Array.isArray(values) ? values.slice(-32) : [])
16
+ }
17
+ return cache.get(path)
18
+ }
19
+ return {
20
+ record(path, from, to, page, added = [], removed = [], removedNodes = []) {
21
+ const list = entries(path); list.push({ from, to, page, added, removed, removedNodes }); if (list.length > 32) list.shift()
22
+ const file = fileOf(path); mkdirSync(dirname(file), { recursive: true })
23
+ writeFileSync(file + '.tmp', JSON.stringify(list)); renameSync(file + '.tmp', file)
24
+ },
25
+ between(path, from, to, page) {
26
+ const list = entries(path), changes = []; let revision = from
27
+ for (let attempt = 0; revision !== to && attempt < 32; attempt++) {
28
+ const entry = list.find((item) => item.from === revision)
29
+ if (!entry) return null
30
+ if (entry.page === page) changes.push(entry)
31
+ revision = entry.to
32
+ }
33
+ if (revision !== to) return null
34
+ return { added: changes.flatMap((item) => item.added), removed: changes.flatMap((item) => item.removed), changes }
35
+ },
36
+ }
37
+ }
38
+
39
+ export function applyTaskPatches(doc, patch, strict = false) {
40
+ const stable = (value) => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
41
+ ? Object.fromEntries(Object.keys(item).sort().map((key) => [key, item[key]])) : item)
42
+ for (const change of patch.changes || [patch]) {
43
+ const removed = new Set(change.removed || [])
44
+ if (strict) {
45
+ for (const before of change.removedNodes || []) {
46
+ const current = doc.nodes.find((node) => node.id === before.id)
47
+ if (current && stable(current) !== stable(before)) throw new Error('后台撤销与本地节点编辑冲突,本地编辑已保留')
48
+ }
49
+ if ((doc.edges || []).some((edge) => removed.has(edge.from) || removed.has(edge.to))) throw new Error('后台撤销与本地连线冲突,本地编辑已保留')
50
+ }
51
+ doc.nodes = doc.nodes.filter((node) => !removed.has(node.id))
52
+ if (Array.isArray(doc.drawOrder)) doc.drawOrder = doc.drawOrder.filter((id) => !removed.has(id))
53
+ for (const node of change.added || []) if (!doc.nodes.some((item) => item.id === node.id)) {
54
+ doc.nodes.push(JSON.parse(JSON.stringify(node)))
55
+ if (Array.isArray(doc.drawOrder) && !doc.drawOrder.includes(node.id)) doc.drawOrder.push(node.id)
56
+ }
57
+ }
58
+ return doc
59
+ }
@@ -0,0 +1,58 @@
1
+ /* 由 tools/build.mjs 生成 —— 请勿直接编辑;改 src/ 下的源文件。 */
2
+ import { parentPort, workerData } from 'node:worker_threads'
3
+ import { readFileSync, writeFileSync, realpathSync, statSync } from 'node:fs'
4
+ import { resolve, relative, isAbsolute, join } from 'node:path'
5
+ import { Resvg } from '@resvg/resvg-js'
6
+ import { MAX_FRAME_SVG_CHARS } from './media.js'
7
+
8
+ // Rendering must not acquire filesystem/network resources referenced by SVGs.
9
+ export function validateExportSvg(svg) {
10
+ if (svg.length > MAX_FRAME_SVG_CHARS || !/<svg\b/i.test(svg)) throw new Error('导出帧不是有效尺寸的 SVG')
11
+ if (/<!DOCTYPE|<!ENTITY|<\?xml-stylesheet|@import|<foreignObject\b/i.test(svg)) throw new Error('导出帧包含不支持的外部资源或 foreignObject')
12
+ for (const match of svg.matchAll(/(?:\b(?:xlink:)?href)\s*=\s*(["'])(.*?)\1/gis)) {
13
+ if (!/^(?:#|data:image\/(?:png|jpe?g|gif|webp);base64,)/i.test(match[2].trim())) throw new Error('导出帧不能引用外部图片或文件')
14
+ }
15
+ for (const match of svg.matchAll(/url\(\s*(["']?)(.*?)\1\s*\)/gis)) {
16
+ if (!match[2].trim().startsWith('#')) throw new Error('导出帧不能引用外部 CSS 资源')
17
+ }
18
+ }
19
+
20
+ export function renderExportFrame(svg, width, height) {
21
+ validateExportSvg(svg)
22
+ // Parse the actual document first: an invalid nested SVG image would
23
+ // otherwise be silently omitted by the outer viewport wrapper.
24
+ const normalized = new Resvg(svg, { font: { loadSystemFonts: /<text\b/i.test(svg) } }).toString()
25
+ // Match drawImage(..., w, h): stretch the intrinsic SVG viewport to the
26
+ // manifest dimensions, retaining its own viewBox/clip behavior inside it.
27
+ const wrapper = '<svg xmlns="http://www.w3.org/2000/svg" width="' + width + '" height="' + height + '">' +
28
+ '<image width="100%" height="100%" preserveAspectRatio="none" href="data:image/svg+xml;base64,' +
29
+ Buffer.from(normalized).toString('base64') + '"/></svg>'
30
+ const image = new Resvg(wrapper, { background: 'white', font: { loadSystemFonts: false } }).render()
31
+ if (image.width !== width || image.height !== height) throw new Error('宿主渲染尺寸与视频不一致')
32
+ return image.asPng()
33
+ }
34
+
35
+ if (parentPort) {
36
+ try {
37
+ const root = realpathSync(workerData.dir)
38
+ let bytes = 0, last = 0
39
+ for (let index = 0; index < workerData.frames.length; index++) {
40
+ const file = workerData.frames[index].file
41
+ if (typeof file !== 'string' || isAbsolute(file)) throw new Error('导出帧路径无效')
42
+ const absolute = realpathSync(resolve(root, file))
43
+ const rel = relative(root, absolute)
44
+ if (isAbsolute(rel) || rel === '..' || rel.startsWith('../') || rel.startsWith('..\\')) throw new Error('导出帧路径超出媒体包')
45
+ if (statSync(absolute).size > MAX_FRAME_SVG_CHARS * 4) throw new Error('导出帧 SVG 过大')
46
+ const begin = performance.now()
47
+ const png = renderExportFrame(readFileSync(absolute, 'utf8'), workerData.w, workerData.h)
48
+ writeFileSync(join(workerData.temp, 'frame-' + String(index).padStart(8, '0') + '.png'), png)
49
+ bytes += png.length
50
+ parentPort.postMessage({ type: 'timing', ms: performance.now() - begin })
51
+ if (Date.now() - last > 100 || index + 1 === workerData.frames.length) {
52
+ parentPort.postMessage({ type: 'progress', rendered: index + 1, bytes }); last = Date.now()
53
+ }
54
+ }
55
+ parentPort.postMessage({ type: 'done', rendered: workerData.frames.length, bytes })
56
+ } catch (error) { parentPort.postMessage({ type: 'error', error: String(error.message || error) }) }
57
+ parentPort.close()
58
+ }