dsh-drawai 0.3.0 → 0.3.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/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
 
@@ -4181,8 +4191,136 @@ function sanitizeNewName(raw) {
4181
4191
  // 临时复刻包只由持有本次令牌的会话管理,取消不触及已有媒体。
4182
4192
  const videoTraceJobs = new Map()
4183
4193
  const videoUploadJobs = new Map()
4184
- const videoExports = createVideoExportManager()
4194
+ const videoExports = createVideoExportManager({ slots: mediaSlots })
4185
4195
  const videoTracePool = createVideoTracePool()
4196
+ const drawingTasks = createDrawingTaskManager({ pool: videoTracePool, slots: mediaSlots,
4197
+ commit: commitDrawingTask, undo: undoDrawingTask,
4198
+ publishExtraction: (job, action) => documents.run(job.scope.drawioAbs, async () => {
4199
+ const loaded = await loadTaskDocument(job)
4200
+ const source = loaded.doc.nodes.find((node) => node.id === job.source.id)
4201
+ if (videoMetaFromData(source?.data)?.clip !== job.source.clip) throw new Error('抽帧来源已更改,结果已保留在任务目录')
4202
+ await checkVideoInput(job)
4203
+ return action()
4204
+ }),
4205
+ extracted: (job, output) => {
4206
+ extractRequests.delete(job.scope.sessionId)
4207
+ extractResults.set(job.scope.sessionId, { absolute: job.scope.drawioAbs, clip: job.source.clip, written: output.written, taskId: job.id })
4208
+ },
4209
+ failed: (job) => {
4210
+ if (job.kind === 'extract') {
4211
+ extractRequests.delete(job.scope.sessionId)
4212
+ extractResults.set(job.scope.sessionId, { absolute: job.scope.drawioAbs, clip: job.source.clip, written: job.done, error: job.error || '抽帧已取消' })
4213
+ }
4214
+ },
4215
+ })
4216
+ const signature = (value) => createHash('sha256').update(value).digest('hex')
4217
+ const stableValue = (value) => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
4218
+ ? Object.fromEntries(Object.keys(item).sort().map((key) => [key, item[key]])) : item)
4219
+ async function loadTaskDocument(job) {
4220
+ let loaded = await loadDoc(job.scope.drawioAbs, job.scope.sessionId, job.scope.page)
4221
+ if (!loaded.exists) throw new Error('原画布已删除,结果已保留')
4222
+ if (job.scope.pageId) {
4223
+ const page = loaded.pageIds.indexOf(job.scope.pageId)
4224
+ if (page < 0 || loaded.pageIds.filter((id) => id === job.scope.pageId).length !== 1) throw new Error('原页面已删除或身份冲突,结果已保留')
4225
+ if (page !== loaded.pageIndex) loaded = await loadDoc(job.scope.drawioAbs, job.scope.sessionId, page)
4226
+ } else if (JSON.stringify(loaded.pageNames) !== JSON.stringify(job.scope.pageNames)) throw new Error('页面身份已改变,结果已保留')
4227
+ return loaded
4228
+ }
4229
+ async function writeTaskDocument(job, loaded, node, removed = []) {
4230
+ const policy = policyFor(job.scope.sessionId)
4231
+ if (policy?.mode === 'read-only') throw new Error('当前会话为只读,绘制结果已保留')
4232
+ const rendered = renderDocText(loaded.text, loaded.doc, { page: loaded.pageIndex })
4233
+ // The coordinator serializes plugin writers; a fresh check also detects
4234
+ // external edits which happened while an asynchronous source check ran.
4235
+ if (contentHash(await ctx.fs.readText(loaded.target)) !== contentHash(loaded.text)) throw new Error('文件已被其他编辑器修改,请重试加入结果')
4236
+ const savedNode = node ? normalizeDoc(parseMxfile(rendered.text, { page: loaded.pageIndex }).doc).nodes.find((item) => item.id === node.id) : null
4237
+ if (savedNode) drawingTasks.recordCommit(job, savedNode)
4238
+ await ctx.fs.writeText(loaded.target, rendered.text, undefined, undefined, policy)
4239
+ taskPatches.record(loaded.absolute, contentHash(loaded.text), rendered.revision, loaded.pageIndex, savedNode ? [savedNode] : [], removed, removed.length && job.createdNode ? [job.createdNode] : [])
4240
+ return { nodeId: savedNode?.id, node: savedNode, page: loaded.pageIndex }
4241
+ }
4242
+ async function commitDrawingTask(job, output) {
4243
+ return documents.run(job.scope.drawioAbs, async () => {
4244
+ const loaded = await loadTaskDocument(job)
4245
+ const existing = loaded.doc.nodes.find((node) => node.data?.drawaiTaskId === job.id)
4246
+ if (existing) return { nodeId: existing.id, node: job.createdNode, page: loaded.pageIndex }
4247
+ const source = loaded.doc.nodes.find((node) => node.id === job.source.id)
4248
+ if (!source) throw new Error('来源节点已删除,结果已保留')
4249
+ const image = job.kind === 'image' ? imageValueFromStyle(source.style || '') : ''
4250
+ if (job.kind === 'image' && signature(image) !== job.source.signature) throw new Error('原图已更改,结果已保留')
4251
+ if (job.kind === 'video') {
4252
+ await checkVideoInput(job)
4253
+ }
4254
+ const node = applyDrawingResult(loaded.doc, { ...job, source: { ...job.source, image } }, output)
4255
+ loaded.doc.meta = { ...loaded.doc.meta, pinned: true }
4256
+ return writeTaskDocument(job, loaded, node)
4257
+ })
4258
+ }
4259
+ async function checkVideoInput(job) {
4260
+ const dir = clipDirFor(job.scope.drawioAbs, job.source.clip), original = findSourceFile(dir, readManifest(dir))
4261
+ if (!original) throw new Error('原视频已删除,结果已保留')
4262
+ const rel = relativePath(realpathSync(dir), realpathSync(original.abs))
4263
+ if (isAbsolute(rel) || rel.startsWith('..') || signature(await readFile(original.abs)) !== job.inputHash) throw new Error('原视频内容或路径已更改,结果已保留')
4264
+ }
4265
+ async function undoDrawingTask(job) {
4266
+ return documents.run(job.scope.drawioAbs, async () => {
4267
+ const loaded = await loadTaskDocument(job)
4268
+ const node = loaded.doc.nodes.find((item) => item.data?.drawaiTaskId === job.id)
4269
+ if (!node) return
4270
+ if (stableValue(node) !== stableValue(job.createdNode)) throw new Error('生成节点已被修改,请在画布中撤销或删除,避免丢失后续编辑')
4271
+ if (loaded.doc.edges.some((edge) => edge.from === node.id || edge.to === node.id)) throw new Error('生成节点已连接新连线,请在画布中处理')
4272
+ loaded.doc.nodes = loaded.doc.nodes.filter((item) => item.id !== node.id)
4273
+ loaded.doc.drawOrder = (loaded.doc.drawOrder || []).filter((id) => id !== node.id)
4274
+ await writeTaskDocument(job, loaded, null, [node.id])
4275
+ })
4276
+ }
4277
+ async function enqueueDrawing(scope, body) {
4278
+ return documents.run(scope.drawioAbs, async () => {
4279
+ if (policyFor(scope.sessionId)?.mode === 'read-only') throw new Error('当前会话为只读,无法创建绘制任务')
4280
+ if (!Number.isInteger(body.page ?? 0) || (body.page ?? 0) < 0) throw new Error('页面位置无效')
4281
+ const loaded = await loadDoc(scope.drawioAbs, scope.sessionId, body.page || 0)
4282
+ if (loaded.pageIndex !== (body.page || 0)) throw new Error('页面已不存在')
4283
+ if (!loaded.exists) throw new Error('请先创建画布')
4284
+ if (body.revision && body.revision !== loaded.doc.revision) throw new Error('画布版本已更新,请等待同步后重试')
4285
+ const kind = body.kind
4286
+ if (!['image', 'video', 'extract'].includes(kind)) throw new Error('未知绘制类型')
4287
+ const source = loaded.doc.nodes.find((node) => node.id === body.sourceId)
4288
+ if (!source) throw new Error('来源节点不存在')
4289
+ const options = { level: 'medium', preset: 'default', colors: 12, removeBg: false, ...(body.options || {}) }
4290
+ if (!['low', 'medium', 'high'].includes(options.level) || !TRACE_PRESET_NAMES.includes(options.preset) ||
4291
+ !Number.isInteger(Number(options.colors)) || options.colors < 2 || options.colors > 24 || typeof options.removeBg !== 'boolean') throw new Error('绘制参数无效')
4292
+ const taskScope = { ...scope, page: loaded.pageIndex, pageId: loaded.pageId, pageNames: loaded.pageNames,
4293
+ pageName: loaded.pageNames[loaded.pageIndex] }
4294
+ let input, taskSource
4295
+ if (kind === 'image') {
4296
+ const image = imageValueFromStyle(source.style || '')
4297
+ if (!isRasterDataUri(image)) throw new Error('请选择位图来源')
4298
+ input = Buffer.from(image.slice(image.indexOf(',') + 1), 'base64')
4299
+ taskSource = { id: source.id, signature: signature(image) }
4300
+ } else {
4301
+ const meta = videoMetaFromData(source.data)
4302
+ if (meta?.kind !== 'raster') throw new Error('请选择原视频来源')
4303
+ const dir = clipDirFor(scope.drawioAbs, meta.clip), original = findSourceFile(dir, readManifest(dir))
4304
+ if (!original) throw new Error('原视频文件缺失')
4305
+ const mediaRel = relativePath(realpathSync(scope.workspaceRoot), realpathSync(dir))
4306
+ if (isAbsolute(mediaRel) || mediaRel.startsWith('..')) throw new Error('视频媒体目录超出工作区')
4307
+ const file = realpathSync(original.abs), rel = relativePath(realpathSync(dir), file)
4308
+ if (isAbsolute(rel) || rel.startsWith('..')) throw new Error('原视频路径超出媒体包')
4309
+ input = file
4310
+ taskSource = { id: source.id, clip: meta.clip, extension: /\.[a-z0-9]+$/i.exec(file)?.[0] || '.mp4', signature: meta.clip + ':' + signature(await readFile(file)) }
4311
+ }
4312
+ return drawingTasks.enqueue(taskScope, { kind, options, source: taskSource, label: source.label || (kind === 'image' ? '图片' : '视频') + ' · ' + source.id }, input)
4313
+ })
4314
+ }
4315
+ async function startHostExtraction(sessionId, loaded, plan) {
4316
+ const current = await loadDoc(loaded.absolute, sessionId, loaded.pageIndex)
4317
+ const source = current.doc.nodes.find((node) => videoMetaFromData(node.data)?.clip === plan.clip)
4318
+ if (!source) throw new Error('抽帧来源节点已不存在')
4319
+ const job = await enqueueDrawing({ sessionId, path: loaded.absolute, drawioAbs: loaded.absolute, workspaceRoot: workspaceRootOf(sessionId) },
4320
+ { kind: 'extract', sourceId: source.id, page: current.pageIndex, options: { ...plan, range: 'segment' } })
4321
+ const pending = extractRequests.get(sessionId)
4322
+ if (pending) pending.taskId = job.id
4323
+ }
4186
4324
  const wakeVideoTrace = (job) => {
4187
4325
  const waiters = job.waiters || []
4188
4326
  job.waiters = []
@@ -4215,9 +4353,8 @@ function sanitizeNewName(raw) {
4215
4353
  job.cancelled = true
4216
4354
  wakeVideoTrace(job)
4217
4355
  }
4218
- videoTracePool.close()
4219
4356
  videoTraceJobs.clear(); videoUploadJobs.clear()
4220
- return videoExports.dispose()
4357
+ return Promise.all([drawingTasks.close(), videoExports.dispose()]).finally(() => videoTracePool.close())
4221
4358
  })
4222
4359
  async function handleSave(req, res) {
4223
4360
  if (req.method !== 'POST') {
@@ -4269,9 +4406,35 @@ function sanitizeNewName(raw) {
4269
4406
  return
4270
4407
  }
4271
4408
 
4409
+ try {
4410
+ const lockAction = body.action === undefined || ['create', 'revert', 'media-cleanup', 'media-video-export-start'].includes(body.action)
4411
+ if (lockAction && typeof (body.path || body.name) === 'string' && workspaceRootOf(body.sessionId)) {
4412
+ const target = await resolveTarget(body.path || body.name, body.sessionId)
4413
+ return await documents.run(absoluteHint(target, body.path || body.name), () => dispatchSave(body, res))
4414
+ }
4415
+ return await dispatchSave(body, res)
4416
+ } catch (error) { sendJson(res, 400, { ok: false, error: messageOf(error) }) }
4417
+ }
4418
+
4419
+ async function dispatchSave(body, res) {
4420
+
4272
4421
  const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined
4273
4422
  const rawPath = typeof body.path === 'string' ? body.path : undefined
4274
4423
 
4424
+ if (typeof body.action === 'string' && body.action.startsWith('drawing-task-')) {
4425
+ if (!sessionOf(sessionId) || !workspaceRootOf(sessionId) || !rawPath?.toLowerCase().endsWith('.drawio')) throw new Error('绘制任务需要当前工作区画布')
4426
+ const target = await resolveTarget(rawPath, sessionId), root = workspaceRootOf(sessionId)
4427
+ if (!ctx.fs.contains(await ctx.fs.resolve(root), target)) throw new Error('画布路径超出工作区')
4428
+ const scope = { sessionId, path: rawPath, drawioAbs: absoluteHint(target, rawPath), workspaceRoot: root }
4429
+ let result
4430
+ if (body.action === 'drawing-task-start') result = { job: await enqueueDrawing(scope, body) }
4431
+ else if (body.action === 'drawing-task-list') result = { jobs: drawingTasks.list(scope) }
4432
+ else if (body.action === 'drawing-task-result') result = drawingTasks.result(scope, body.taskId)
4433
+ else if (body.action === 'drawing-task-command') result = { job: await drawingTasks.command(scope, body.taskId, body.command, body.value) }
4434
+ else throw new Error('未知绘制任务操作')
4435
+ sendJson(res, 200, { ok: true, ...result }); return
4436
+ }
4437
+
4275
4438
  // 仅计算图像节点的 SVG;由客户端通过同一历史/保存通道应用结果。
4276
4439
  if (body.action === 'trace-node-image') {
4277
4440
  if (sessionId === undefined || sessionOf(sessionId) === undefined || workspaceRootOf(sessionId) === undefined) {
@@ -4449,7 +4612,7 @@ function sanitizeNewName(raw) {
4449
4612
  // AI 下发的抽帧计划同理:画布取走后用浏览器解码视频、写 stills/,完成后 POST 回 media-extract-result。
4450
4613
  const pendingExtract = extractRequests.get(sessionId)
4451
4614
  const extractForThis =
4452
- pendingExtract !== undefined && pendingExtract.absolute === loaded.absolute
4615
+ pendingExtract !== undefined && !pendingExtract.host && pendingExtract.absolute === loaded.absolute
4453
4616
  ? {
4454
4617
  requestId: pendingExtract.requestId,
4455
4618
  clip: pendingExtract.clip,
@@ -4471,6 +4634,8 @@ function sanitizeNewName(raw) {
4471
4634
  pageCount: loaded.pageCount,
4472
4635
  pageIndex: loaded.pageIndex,
4473
4636
  pageNames: loaded.pageNames,
4637
+ backgroundPatch: typeof body.revision === 'string' && body.revision !== loaded.doc.revision
4638
+ ? taskPatches.between(loaded.absolute, body.revision, loaded.doc.revision, loaded.pageIndex) : null,
4474
4639
  highlight: Array.isArray(pendingHighlight) ? pendingHighlight : [],
4475
4640
  canRevert: revertDepth(snapshotKey(sessionId, loaded.absolute)) > 0,
4476
4641
  // 用户当前的选区(客户端可选地用来做提示;AI 侧由工具读回)。
@@ -4589,10 +4754,12 @@ function sanitizeNewName(raw) {
4589
4754
  }
4590
4755
  let revertTarget
4591
4756
  let revertAbsolute
4757
+ let revertCurrent
4592
4758
  try {
4593
4759
  const loaded = await loadDoc(rawPath, sessionId)
4594
4760
  revertTarget = loaded.target
4595
4761
  revertAbsolute = loaded.absolute
4762
+ revertCurrent = loaded.text
4596
4763
  } catch (error) {
4597
4764
  sendJson(res, 400, { ok: false, error: '这个文件读不出画布:' + messageOf(error) })
4598
4765
  return
@@ -4604,6 +4771,16 @@ function sanitizeNewName(raw) {
4604
4771
  sendJson(res, 200, { ok: false, error: '这张画布没有可退回的 AI 改动了' })
4605
4772
  return
4606
4773
  }
4774
+ if (contentHash(revertCurrent) !== snapshot.revision) {
4775
+ const taskNodes = (text) => {
4776
+ const first = parseMxfile(text), nodes = []
4777
+ for (let page = 0; page < first.pageCount; page++) nodes.push(...parseMxfile(text, { page }).doc.nodes.filter((node) => node.data?.drawaiTaskId))
4778
+ return nodes
4779
+ }
4780
+ if (stableValue(taskNodes(revertCurrent)) !== stableValue(taskNodes(snapshot.text))) {
4781
+ sendJson(res, 409, { ok: false, error: 'AI 改动后已有后台生成或编辑的结果,请先在任务队列中撤销生成,避免整页回退丢失结果' }); return
4782
+ }
4783
+ }
4607
4784
  const policy = policyFor(sessionId)
4608
4785
  try {
4609
4786
  if (policy === undefined) await ctx.fs.writeText(revertTarget, snapshot.text)
@@ -4907,8 +5084,7 @@ function sanitizeNewName(raw) {
4907
5084
  let result
4908
5085
  switch (body.action) {
4909
5086
  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
5087
+ case 'media-video-export-list': result = { jobs: videoExports.list(scope) }; break
4912
5088
  case 'media-video-export-finish': result = videoExports.finish(scope, body.token); break
4913
5089
  case 'media-video-export-status': result = videoExports.status(scope, body.token); break
4914
5090
  case 'media-video-export-read': result = videoExports.read(scope, body.token, body.offset); break
@@ -5336,8 +5512,15 @@ function sanitizeNewName(raw) {
5336
5512
  return
5337
5513
  }
5338
5514
  if (body.action === 'media-cleanup') {
5339
- const doc = body.doc
5340
- const removed = cleanupUnreferencedClips(drawioAbs, doc)
5515
+ const current = await loadDoc(drawioAbs, sessionId, 0)
5516
+ const nodes = [...(body.doc?.nodes || [])]
5517
+ for (let page = 0; page < current.pageNames.length; page++) {
5518
+ const parsed = page === 0 ? current : await loadDoc(drawioAbs, sessionId, page)
5519
+ nodes.push(...parsed.doc.nodes)
5520
+ }
5521
+ const leases = new Set([...drawingTasks.leases(drawioAbs, { workspaceRoot: mediaRoot }), ...videoExports.leases(drawioAbs)])
5522
+ for (const job of videoTraceJobs.values()) if (job.drawioAbs === drawioAbs) leases.add(job.clip)
5523
+ const removed = cleanupUnreferencedClips(drawioAbs, { nodes }, leases)
5341
5524
  sendJson(res, 200, { ok: true, removed: removed })
5342
5525
  return
5343
5526
  }
@@ -5508,14 +5691,18 @@ function sanitizeNewName(raw) {
5508
5691
  sendJson(res, 409, { ok: false, error: 'already exists', path: absolute })
5509
5692
  return
5510
5693
  }
5694
+ let backgroundPatch = null
5511
5695
  if (baseRevision !== undefined && exists && contentHash(originalText) !== baseRevision) {
5512
- sendJson(res, 409, { ok: false, error: 'revision conflict', currentRevision: contentHash(originalText) })
5513
- return
5696
+ backgroundPatch = taskPatches.between(absolute, baseRevision, contentHash(originalText), Number(body.page) || 0)
5697
+ if (!backgroundPatch) {
5698
+ sendJson(res, 409, { ok: false, error: 'revision conflict', currentRevision: contentHash(originalText) }); return
5699
+ }
5514
5700
  }
5515
5701
 
5516
5702
  // 人工编辑一律打 pinned:从此这张画布是人摆过版面的,AI 的自动布局不该再冲掉它。
5517
5703
  // (空文件 → 从零生成;已有文件 → 无损写回,见 renderDocText。)
5518
5704
  const next = normalizeDoc(doc)
5705
+ if (backgroundPatch) applyTaskPatches(next, backgroundPatch, true)
5519
5706
  next.meta = Object.assign({}, next.meta, { pinned: true })
5520
5707
  let rendered
5521
5708
  try {
@@ -5544,13 +5731,18 @@ function sanitizeNewName(raw) {
5544
5731
  // 媒体复制失败不回滚画布写入:海报仍在;播放时会报媒体包缺失。
5545
5732
  }
5546
5733
  }
5547
- sendJson(res, 200, { ok: true, revision: rendered.revision, path: absolute, dropped: rendered.dropped })
5734
+ sendJson(res, 200, { ok: true, revision: rendered.revision, path: absolute, dropped: rendered.dropped, ...(backgroundPatch ? { backgroundPatch } : {}) })
5548
5735
  }
5549
5736
 
5550
5737
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: SAVE_PATH, handler: handleSave }), 'drawai: ' + SAVE_PATH)
5551
5738
 
5552
5739
  ctx.effect(() => ctx.tools.register(readTool))
5553
- ctx.effect(() => ctx.tools.register(applyTool))
5740
+ ctx.effect(() => ctx.tools.register({ ...applyTool, execute: async (args, exec) => {
5741
+ const sessionId = sessionIdOf(exec)
5742
+ const path = typeof args.path === 'string' && args.path ? args.path : focusedPathFor(sessionId) || DEFAULT_PATH
5743
+ const target = await resolveTarget(path, sessionId)
5744
+ return documents.run(absoluteHint(target, path), () => applyTool.execute(args, exec))
5745
+ } }))
5554
5746
 
5555
5747
  // 把"怎么用这张画布"注册成**内嵌技能**(见 SKILL_BODY 的注释):
5556
5748
  // 换了电脑、只装了插件(没有源码/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
+ }