collab-canvas 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/host.js CHANGED
@@ -29,6 +29,36 @@ function slugify(title) {
29
29
  }
30
30
  function err(code, message) { return { ok: false, error: { code: code, message: message } } }
31
31
 
32
+ // ── HTTP 响应辅助(2026-10-26 重构 P2:从 09-http / 11-extras 各一份合并到这里)──
33
+ // 原来的问题是两个文件各写一份同名函数 ✗ 改一处忘一处就行为不一致 ✓
34
+ // 注意两者的**语义差异**(合并时必须保留 ✓):
35
+ // · httpReadBody(req) → 返回 **Buffer**(上传要用原始字节 ✓ 支持大小上限)
36
+ // · httpReadJson(req) → 返回**解析后的对象**(普通 RPC 用 ✓ 解析失败抛错)
37
+ function httpJson(res, data) {
38
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' })
39
+ res.end(JSON.stringify(data))
40
+ }
41
+ function httpError(res, status, data) {
42
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
43
+ res.end(JSON.stringify(data))
44
+ }
45
+ function httpReadBody(req, limit) {
46
+ return new Promise((resolve, reject) => {
47
+ const chunks = []
48
+ let size = 0
49
+ req.on('data', (c) => {
50
+ size += c.length
51
+ if (limit && size > limit) { reject(new Error('文件超过大小上限')); try { req.destroy() } catch (_) {} ; return }
52
+ chunks.push(c)
53
+ })
54
+ req.on('end', () => resolve(Buffer.concat(chunks)))
55
+ req.on('error', reject)
56
+ })
57
+ }
58
+ function httpReadJson(req) {
59
+ return httpReadBody(req).then((buf) => JSON.parse(buf.toString('utf8')))
60
+ }
61
+
32
62
  // ==================== 03-state.js ====================
33
63
  // 画布注册表状态 + 存储根多级探测
34
64
  var canvases = new Map() // id -> {id,title,type,content,version,filePath,history[],redoStack[],dirty,updatedAt}
@@ -175,8 +205,22 @@ async function saveCanvas(c, filePath) {
175
205
  }
176
206
 
177
207
  async function persistMeta() {
178
- const payload = JSON.stringify({ activeId: activeId, rootOverride: rootOverride, canvases: Array.from(canvases.values()).map(function (c) {
179
- return { id: c.id, title: c.title, type: c.type, filePath: c.filePath }
208
+ const payload = JSON.stringify({ activeId: activeId, rootOverride: rootOverride, canvases: Array.from(canvases.values()).filter(function (c) {
209
+ // ⚠️ 选区编辑区(type='scratch')**不落盘**(2026-10-11 加)。
210
+ // 它是一次性对象:拼接成功后立刻销毁;重启即消失。
211
+ // 规格见 canvas-docs/话布选区编辑区方案.md 第四、九节。
212
+ // 一旦写进 meta 就不再"一次性"了 —— 实施步骤 6 专门验这条。
213
+ return c && c.type !== 'scratch'
214
+ }).map(function (c) {
215
+ // ⚠️ version 必须存 —— 2026-10-04 补。
216
+ // 它原本不存,于是 restore 时一律 `version: 1`,后果有两个:
217
+ // ① **所有靠版本号的保护在重启后失效**:草稿基准校验(草稿记的 base=1、
218
+ // 服务端重启后也是 1 → 误判成"基准相符" → 把过期草稿写回去覆盖新内容)、
219
+ // 并发冲突校验(knownVer 走的是同一套版本号)都形同虚设;
220
+ // ② **事后无法追溯**:撤销每次都会把版本 +1,本来能数出"被撤了几次",
221
+ // 重启一重置就再也看不出来(2026-10-04 那次误判就是这么丢的线索 ——
222
+ // 把"主人点了 3 次撤销"误判成了"面板覆盖")。
223
+ return { id: c.id, title: c.title, type: c.type, filePath: c.filePath, version: c.version }
180
224
  }) }, null, 2)
181
225
  // 同步写所有候选位置:冷启动时无论读到哪一份都带 rootOverride
182
226
  const targets = metaTargets()
@@ -212,8 +256,49 @@ async function flushDirty() {
212
256
  const debouncedFlush = ctx.debounce(function () { flushDirty() }, 1500)
213
257
  ctx.effect(function () { return function () { flushDirty() } }, 'final-flush')
214
258
 
259
+ // ── 每轮结束落一次盘(2026-10-14 加)─────────────────────────────
260
+ // 为什么:AI 用 canvas_write 写画布走的是 applyWrite → debouncedFlush(**1.5 秒** ✗),
261
+ // 进程/会话在那一秒半里重启就丢 ✗。而"一轮结束"是很自然的检查点 ✓
262
+ // —— 把暴露窗口从"几轮"缩到"一轮之内" ✓
263
+ // 主人 2026-10-14 的现场:AI 追加的附录因会话重启全丢 ✗(根因是 restore 覆盖,
264
+ // 但"更早落盘"能把这个坑填得更死 ✓ 两道一起上 ✓)
265
+ //
266
+ // ⚠️ 挂 **ctx.root**,不是插件自己的 ctx —— 作用域树上插件 ctx 是 agent 作用域的
267
+ // **兄弟分支** ✗ 事件只往上冒到祖先 ✓(2026-10-11 在拼接那边踩过这个坑 ✓)
268
+ try {
269
+ var flushEvCtx = (ctx && ctx.root) ? ctx.root : ctx
270
+ if (flushEvCtx && typeof flushEvCtx.on === 'function') {
271
+ flushEvCtx.on('agent/turn-stopping', function () { try { flushDirty() } catch (_) {} })
272
+ flushEvCtx.on('session/event', function (session, event) {
273
+ try { if (event && event.type === 'turn/end') flushDirty() } catch (_) {}
274
+ })
275
+ console.log('[collab-canvas]', 'turn-end flush registered')
276
+ }
277
+ } catch (e) { console.error('[collab-canvas] turn-end flush 注册失败:', e && e.message) }
278
+
279
+ // [[CCV-RESTORE-BEGIN]] —— 到 END 之间是「启动/重载时恢复画布」的全部代码。
280
+ // tools/test-host-restore.cjs 会把这一段切出来跑(标记没了它立刻报错,不会悄悄测过期副本)。
215
281
  async function restore() {
216
282
  if (!fs) { console.error('[collab-canvas]', 'fs service unavailable — persistence disabled'); return }
283
+
284
+ // 🔴🔴 **先把内存里没落盘的改动写下去,再读文件**(2026-10-14 加,修数据丢失)
285
+ //
286
+ // 为什么必须放在最前面:restore() 会在**同一个进程里被重跑** ——
287
+ // 会话重启 / 插件重载都会让它再跑一次 ✓ 而那时 canvases 里**还留着**上一轮的
288
+ // 内容(可能比文件新 ✓ 因为落盘是 1.5 秒防抖的 ✗)。
289
+ // 它下面无条件 `content = 读文件` + `canvases.set(...)` ——
290
+ // 等于**拿旧文件覆盖新内存** ✗✗
291
+ //
292
+ // 主人 2026-10-14 的现场(别的项目里):
293
+ // AI 用 canvas_write 追加了附录 A/B/C,几轮之后会话重启 →
294
+ // 插件重新初始化 → restore 从文件读回 9752 字节的**原始版** →
295
+ // 覆盖掉内存里的新版 → **附录全丢** ✗ 而且 dirty 被设成 false,
296
+ // 连"还没存"这件事都看不出来了 ✗
297
+ // 同一个根因还造成了"话布登记被重置"(meta 也没落盘 ✓)
298
+ //
299
+ // 冷启动时内存是空的 → 这一步是空操作 ✓ 不会有副作用 ✓
300
+ try { await flushDirty() } catch (e) { console.error('[collab-canvas] restore 前落盘失败:', e && e.message) }
301
+
217
302
  const metaCandidates = metaTargets()
218
303
  const metas = []
219
304
  for (const mp of metaCandidates) {
@@ -249,9 +334,23 @@ async function restore() {
249
334
  try { content = await fs.readText(await fs.resolve(m.filePath)) }
250
335
  catch (e) { console.error('[collab-canvas] restore miss:', m.filePath, e.message); continue }
251
336
  }
337
+ // 🔴 **绝不拿文件覆盖"内存里有未保存改动"的那一份**(2026-10-14 加)
338
+ // 上面已经 flushDirty 过一次 ✓ 正常情况这里不会命中;
339
+ // 但落盘可能失败(OneDrive 占用、磁盘满、权限 ✗)—— 那时 dirty 还是 true ✓
340
+ // 这份内存内容比文件新 ✓ 用文件覆盖它就是**丢数据** ✗✗
341
+ // 所以:保留内存那份(内容/历史/脏标记都不动),只把 meta 里能补的补上 ✓
342
+ const live = canvases.get(m.id)
343
+ if (live && live.dirty) {
344
+ if (m.filePath && !live.filePath) live.filePath = m.filePath
345
+ if (typeof m.version === 'number' && m.version > live.version) live.version = m.version
346
+ console.error('[collab-canvas] restore: 保留内存里未落盘的改动(不覆盖)', m.id, m.title)
347
+ continue
348
+ }
252
349
  canvases.set(m.id, {
253
350
  id: m.id, title: m.title, type: m.type || 'document', content: content,
254
- version: 1, filePath: m.filePath || null, history: [], redoStack: [],
351
+ // 版本号跟着 meta 一起存(见 persistMeta 的注释):拿不到才退回 1
352
+ version: (typeof m.version === 'number' && m.version > 0) ? m.version : 1,
353
+ filePath: m.filePath || null, history: [], redoStack: [],
255
354
  dirty: false, updatedAt: Date.now(),
256
355
  })
257
356
  }
@@ -268,16 +367,81 @@ async function restore() {
268
367
  console.log('[collab-canvas]', 'restore failed:', e.message)
269
368
  }
270
369
  }
370
+ // [[CCV-RESTORE-END]]
271
371
 
272
372
  // ==================== 05-mutations.js ====================
273
373
  // 共享变更逻辑(RPC 与模型工具共用同一套历史栈)
374
+ // [[CCV-UNDO-BEGIN]] —— 到 END 之间是「历史栈 + 撤销/重做」的全部代码,
375
+ // tools/test-host-undo.cjs 会把这一段切出来跑(标记没了它立刻报错,不会悄悄测过期副本)。
274
376
  var HISTORY_MAX = 50
377
+ // 连续编辑合并的时间窗(见 pushHistory 里的说明)
378
+ var HISTORY_MERGE_MS = 60 * 1000
275
379
  function pushHistory(c, before, after, source, version) {
380
+ // ★ 合并连续编辑(2026-10-11 加)—— **自动保存的前提** ✓
381
+ // 为什么要它:接了自动保存之后,停手 1.5 秒就可能写一次 ✓ 若每次都落一条历史 ✗
382
+ // 撤销栈会被冲爆(按一次撤销只退一小步 ✗ 主人会以为"撤不动"✗)。
383
+ // 成熟文档软件都做这件事(Google Docs 把短时间内的连续编辑合成一个版本 ✓)。
384
+ //
385
+ // 判据三条,缺一不可:
386
+ // ① 来源相同 —— AI 改的与人改的不该合成一步 ✗(撤销时想分清是谁改的 ✓)
387
+ // ② 时间相近 —— 隔太久的两段编辑该分开,否则撤销粒度太粗 ✗
388
+ // ③ **首尾相接**:新一次的 before === 上一条的 after ✓✓ —— 这条最硬,
389
+ // 它证明中间**没有任何别的改动**(撤销 / 重做 / 别处的写入都会破坏它 ✓)
390
+ var last = c.history.length ? c.history[c.history.length - 1] : null
391
+ if (last && last.source === source
392
+ && (Date.now() - (last.at || 0)) < HISTORY_MERGE_MS
393
+ && last.after === before) {
394
+ last.after = after // 终点往后延 ✓ 起点不动 ✓
395
+ last.at = Date.now()
396
+ last.version = version
397
+ c.redoStack.length = 0
398
+ return
399
+ }
276
400
  c.history.push({ opId: 'op-' + Date.now().toString(36) + '-' + (++idSeq), source: source, before: before, after: after, at: Date.now(), version: version })
277
401
  if (c.history.length > HISTORY_MAX) c.history.shift()
278
402
  c.redoStack.length = 0
279
403
  }
280
404
 
405
+ // ═══ 撤销 / 重做 ══════════════════════════════════════════════════
406
+ // 为什么抽成函数:包级 RPC(06-rpc)和 HTTP 端点(09-http)都要用它。
407
+ // 两处各写一套的话,日后改一处漏一处 —— meta 落点那次故障就是这么来的
408
+ // (persistMeta 与 restore 各持一份候选清单,DSH 升级后两者都没包含真正的存储根)。
409
+ //
410
+ // ⚠️ 只回退 content,**不往 history 里塞新条目**:否则「撤销」本身变成一步可撤销的操作,
411
+ // 按一次栈也跟着长,用户会觉得撤不回去(撤了 A,栈顶又变成刚才那次撤销)。
412
+ // ⚠️ version 必须 +1:客户端靠 knownVer 做并发保护,版本不动的话它下一次写入
413
+ // 还是拿着旧版本号 → 被自己挡下或误判成冲突。返回新版本号,客户端据此同步。
414
+ // ⚠️ dirty + debouncedFlush:撤销的结果要落盘,否则刷新后撤销白做。
415
+ function undoCanvas(c) {
416
+ if (!c) return { ok: false, error: '画布不存在' }
417
+ if (!c.history.length) return { ok: true, noop: true, content: c.content, version: c.version }
418
+ const rec = c.history.pop()
419
+ c.redoStack.push(rec)
420
+ c.content = rec.before
421
+ c.version += 1
422
+ c.dirty = true
423
+ c.updatedAt = Date.now()
424
+ debouncedFlush()
425
+ return { ok: true, content: c.content, version: c.version, source: rec.source || null, at: rec.at || null }
426
+ }
427
+ function redoCanvas(c) {
428
+ if (!c) return { ok: false, error: '画布不存在' }
429
+ if (!c.redoStack.length) return { ok: true, noop: true, content: c.content, version: c.version }
430
+ const rec = c.redoStack.pop()
431
+ c.history.push(rec)
432
+ c.content = rec.after
433
+ c.version += 1
434
+ c.dirty = true
435
+ c.updatedAt = Date.now()
436
+ debouncedFlush()
437
+ return { ok: true, content: c.content, version: c.version, source: rec.source || null, at: rec.at || null }
438
+ }
439
+ // 给界面用:按钮该不该灰掉。调用方在 read/write/undo/redo 的响应里带上它。
440
+ function canUndo(c) { return !!(c && c.history && c.history.length) }
441
+ function canRedo(c) { return !!(c && c.redoStack && c.redoStack.length) }
442
+ // [[CCV-UNDO-END]]
443
+
444
+
281
445
  function createCanvasDoc(title) {
282
446
  // 标题净化:AI/导入可能把 "xxx.md" 当标题传入,canvas_save 落盘时会变成
283
447
  // "xxx.md.md" 双扩展名文件——这里统一剥掉尾扩展名
@@ -291,6 +455,64 @@ function createCanvasDoc(title) {
291
455
  return c
292
456
  }
293
457
 
458
+ // ─── 选区编辑区(scratch 画布)─────────────────────────────────────
459
+ // 规格:canvas-docs/话布选区编辑区方案.md(第三、四节)
460
+ //
461
+ // 形态:一个**临时画布**,type='scratch'。借画布的壳只为两件事 ——
462
+ // ① 给 AI 一条**现成的写入通道**(canvas_write,不用给它造新工具)
463
+ // ② 白得一套版本号与历史栈(撤销直接可用)
464
+ // 三条约束(第九节「明确不做」):
465
+ // · 不进任何清单(list 端点过滤 type==='scratch')
466
+ // · 不写进 meta(persistMeta 过滤)—— 一次性对象,重启即消失
467
+ // · 不给人看、不给人改(没有界面)
468
+ //
469
+ // ⚠️ 刻意**不调用** persistMeta / debouncedFlush —— 它是内存对象,
470
+ // 一旦落盘就不再"一次性"了(步骤 6 专门验这条)。
471
+ let scratchSeq = 0
472
+ function createScratchCanvas(b) {
473
+ b = b || {}
474
+ const id = 'scratch-' + Date.now().toString(36) + '-' + (++scratchSeq)
475
+ const quote = String(b.quote || '')
476
+ const c = {
477
+ id: id, title: '选区片段', type: 'scratch',
478
+ content: quote, version: 1, filePath: null,
479
+ history: [], redoStack: [], dirty: false, updatedAt: Date.now(),
480
+ // 绑定:主文档 + 位置 + 指纹。位置由 host 侧按**引文与序数**解析
481
+ //(与「位置主动注入」用同一套算法 —— 渲染前后出现顺序不变,口径一致)。
482
+ scratch: {
483
+ docId: b.docId || null,
484
+ docPath: b.docPath || null,
485
+ offset: (typeof b.offset === 'number') ? b.offset : -1,
486
+ length: (typeof b.length === 'number' && b.length >= 0) ? b.length : quote.length,
487
+ docVersion: (typeof b.docVersion === 'number') ? b.docVersion : 0,
488
+ ordinal: (typeof b.ordinal === 'number') ? b.ordinal : 0,
489
+ dupCount: (typeof b.dupCount === 'number') ? b.dupCount : 0,
490
+ // 哪一笔上报建的这个区 —— 注入时靠它把「这一轮引用到的」认出来
491
+ pinCode: (typeof b.pinCode === 'number') ? b.pinCode : 0,
492
+ // ⚠️ **建区时的原始引文**(2026-10-11 加,拼接要用)。
493
+ // 为什么必须单独留一份:编辑区的内容会被 AI 改掉 ✗,而拼接前要校验
494
+ // "主文档 [偏移, 偏移+长度) 那一段还是不是原来那段" —— 拿被改过的内容去比
495
+ // 必然比不过 ✗。
496
+ origQuote: quote,
497
+ spliced: false,
498
+ at: Date.now(),
499
+ },
500
+ }
501
+ canvases.set(id, c)
502
+ return c
503
+ }
504
+
505
+ // 找某个 pin 建出来的编辑区(没有就返回 null)
506
+ function scratchForPin(pinCode) {
507
+ if (!pinCode) return null
508
+ let hit = null
509
+ canvases.forEach(function (c) {
510
+ if (hit) return
511
+ if (c && c.type === 'scratch' && c.scratch && c.scratch.pinCode === pinCode) hit = c
512
+ })
513
+ return hit
514
+ }
515
+
294
516
  // ═══ AI 改动记录(喂给画布端的「跳过去给我看」)══════════════════════
295
517
  // 需求(主人 2026-09-15):AI 改完文档后,画布自动跳到改动处,让主人当场核对。
296
518
  //
@@ -467,7 +689,10 @@ if (typeof harness !== 'undefined') {
467
689
  harness.handle('canvas.list', async function () {
468
690
  const arr = []
469
691
  canvases.forEach(function (c) {
470
- arr.push({ id: c.id, title: c.title, type: c.type, updatedAt: c.updatedAt, hasFile: !!c.filePath })
692
+ // ★ 2026-10-22:补上 filePath ✓(主人要在右侧栏「话布」清单里【点一下跳到文档】✓)
693
+ // 原来只回 hasFile ✗ → 前端拿不到路径 → 那个清单只能是死文字 ✓
694
+ // ⚠️ 没必要藏它:它本来就是本机路径 ✓ 而这个接口只有本机 UI 在用 ✓
695
+ arr.push({ id: c.id, title: c.title, type: c.type, updatedAt: c.updatedAt, hasFile: !!c.filePath, filePath: c.filePath || null })
471
696
  })
472
697
  return { ok: true, canvases: arr, activeId: activeId, docsDir: docsDir() }
473
698
  })
@@ -504,29 +729,14 @@ harness.handle('canvas.edit', async function (args) {
504
729
  harness.handle('canvas.undo', async function (args) {
505
730
  const c = canvases.get(args && args.canvasId)
506
731
  if (!c) return err('E_NOT_FOUND', '画布不存在')
507
- if (!c.history.length) return { ok: true, noop: true }
508
- const rec = c.history.pop()
509
- c.redoStack.push(rec)
510
- c.content = rec.before
511
- c.version += 1
512
- c.dirty = true
513
- c.updatedAt = Date.now()
514
- debouncedFlush()
515
- return { ok: true, content: c.content, version: c.version }
732
+ // 逻辑在 05-mutations 的 undoCanvas —— 与 HTTP 端点共用同一份(别在这儿另写一套)
733
+ return undoCanvas(c)
516
734
  })
517
735
 
518
736
  harness.handle('canvas.redo', async function (args) {
519
737
  const c = canvases.get(args && args.canvasId)
520
738
  if (!c) return err('E_NOT_FOUND', '画布不存在')
521
- if (!c.redoStack.length) return { ok: true, noop: true }
522
- const rec = c.redoStack.pop()
523
- c.history.push(rec)
524
- c.content = rec.after
525
- c.version += 1
526
- c.dirty = true
527
- c.updatedAt = Date.now()
528
- debouncedFlush()
529
- return { ok: true, content: c.content, version: c.version }
739
+ return redoCanvas(c)
530
740
  })
531
741
 
532
742
  harness.handle('canvas.save', async function (args) {
@@ -614,7 +824,7 @@ ctx.tools.register(defineTool({
614
824
  if (!title) return toolOutput(err('E_BAD_ARGS', 'title 必须为非空字符串'))
615
825
  const c = createCanvasDoc(title)
616
826
  // 会话清单登记:AI 建的画布归入浏览器最近在看的会话清单(面板轮询可见)
617
- try { sessAdd(lastBrowserSid, c.id) } catch (_) {}
827
+ try { sessAddExclusive(lastBrowserSid, c.id) } catch (_) {}
618
828
  return toolOutput({ ok: true, canvasId: c.id, title: title, hint: '已创建并设为活跃画布,接着可用 canvas_write 写入内容。本轮结束前记得调用 present 声明 "canvas-docs/' + slugify(c.title) + '.md"——它会在回复末尾生成一张可点卡片,点开就是话布面板。' })
619
829
  },
620
830
  }))
@@ -695,7 +905,7 @@ ctx.tools.register(defineTool({
695
905
  try {
696
906
  const c = await loadFileDoc(p, args && args.title)
697
907
  // 会话清单登记:AI 加载的文档归入浏览器最近在看的会话清单(面板轮询可见)
698
- try { sessAdd(lastBrowserSid, c.id) } catch (_) {}
908
+ try { sessAddExclusive(lastBrowserSid, c.id) } catch (_) {}
699
909
  return toolOutput({ ok: true, canvasId: c.id, title: c.title, chars: c.content.length, hint: '已加载为画布并设为活跃。' })
700
910
  } catch (e) {
701
911
  return toolOutput(err(e.message.indexOf('ENOENT') >= 0 ? 'E_NOT_FOUND' : 'E_IO', '读取失败:' + e.message))
@@ -961,6 +1171,36 @@ function sessState(sid) {
961
1171
  }
962
1172
  return sessStore.get(sid)
963
1173
  }
1174
+ // ★ 2026-10-24:分组改成【唯一归属】(一篇文档只属于一个会话)★
1175
+ // 事故(主人现场):「我拖动换组怎么成这样了」
1176
+ // 根因:sessAdd 只往新组**追加**,从不把文档从旧组摘掉 ✗
1177
+ // → 同一篇文档同时挂在好几个 .json 里 ✓
1178
+ // → 界面按"先遇到的组"显示它 ✗ → 它跑到没预期的组里 / 拖走以后旧组那份还在 ✓
1179
+ // 现在:登记到某组时,顺手把它从**其它所有组**里摘掉 ✓
1180
+ // 代价:要读一遍 .json(21 个,实测 11~16ms ✓)—— 只在打开/新建文档时发生 ✓
1181
+ function sessAddExclusive(sid, id) {
1182
+ // ⚠️ 没有会话键、或键就是历史兜底桶 'default' → **不登记** ✓
1183
+ // 别再把文档塞进那个假会话里 ✗(2026-10-24 那批就是这么进去的 ✓)
1184
+ if (!sid || String(sid) === 'default') return
1185
+ const target = sessSafe(sid)
1186
+ // ① 先从其它组里摘掉 ✓(不碰目标组 ✓)
1187
+ let files = []
1188
+ try { files = nodefs.readdirSync(sessDirPath()).filter(f => /\.json$/i.test(f)) } catch (_) {}
1189
+ for (const f of files) {
1190
+ const key = String(f).replace(/\.json$/i, '')
1191
+ if (key === target) continue // 目标组不动 ✓
1192
+ const fp = joinPath(sessDirPath(), f)
1193
+ let j = null
1194
+ try { j = JSON.parse(nodefs.readFileSync(fp, 'utf8')) } catch (_) { continue }
1195
+ if (!j || !Array.isArray(j.ids) || j.ids.indexOf(id) < 0) continue
1196
+ j.ids = j.ids.filter(x => x !== id)
1197
+ const nextActive = (j.activeId === id) ? (j.ids[0] || null) : (j.activeId || null)
1198
+ try { nodefs.writeFileSync(fp, JSON.stringify({ activeId: nextActive, ids: j.ids }, null, 2)) } catch (_) {}
1199
+ sessStore.delete(key)
1200
+ }
1201
+ // ② 再登记到目标组 ✓
1202
+ sessAdd(sid, id)
1203
+ }
964
1204
  function sessAdd(sid, id) {
965
1205
  const st = sessState(sid)
966
1206
  if (st.ids.indexOf(id) < 0) { st.ids.push(id); sessPersist(sid, st) }
@@ -974,27 +1214,9 @@ function sessRemove(sid, id) {
974
1214
  }
975
1215
 
976
1216
  function initCanvasHttpEndpoints(ctx, webServer) {
977
- // JSON 响应辅助
978
- function json(res, data) {
979
- res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' })
980
- res.end(JSON.stringify(data))
981
- }
982
- function error(res, status, data) {
983
- res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
984
- res.end(JSON.stringify(data))
985
- }
986
- // 解析 POST body 为 JSON
987
- function readBody(req) {
988
- return new Promise((resolve, reject) => {
989
- const chunks = []
990
- req.on('data', (c) => chunks.push(c))
991
- req.on('end', () => {
992
- try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))) }
993
- catch (e) { reject(new Error('Invalid JSON')) }
994
- })
995
- req.on('error', reject)
996
- })
997
- }
1217
+ // HTTP 响应辅助:2026-10-26 重构 P2 起统一由 01-utils.js 提供
1218
+ // httpJson / httpError / httpReadBody(Buffer) / httpReadJson(解析对象)
1219
+ // 原来是本文件与 11-canvas-extras 各写一份 ✗ 语义还不完全一致(Buffer vs JSON)
998
1220
  // GET /api/canvas/list?sid=xxx —— 只返回该会话清单里的画布
999
1221
  // ?scope=all —— 返回全部画布(跨会话)。客户端做「标题 → 可点链接」
1000
1222
  // 匹配时用:会话清单只含「本会话开过/建过的」,
@@ -1006,26 +1228,128 @@ function initCanvasHttpEndpoints(ctx, webServer) {
1006
1228
  const url = new URL(req.url, 'http://x')
1007
1229
  if (url.searchParams.get('scope') === 'all') {
1008
1230
  const all = []
1009
- canvases.forEach((c) => all.push({ id: c.id, title: c.title, version: c.version, path: canvasMdPath(c) }))
1010
- json(res, { ok: true, canvases: all, activeId: activeId, scope: 'all' })
1231
+ // ⚠️ 选区编辑区(type='scratch')**不进任何清单**(2026-10-11 加)。
1232
+ // 它不是给人看的,是 AI 的工作台(规格第九节「明确不做」:不加分组、不加行)。
1233
+ canvases.forEach((c) => { if (c && c.type !== 'scratch') all.push({ id: c.id, title: c.title, version: c.version, path: canvasMdPath(c) }) })
1234
+ httpJson(res, { ok: true, canvases: all, activeId: activeId, scope: 'all' })
1235
+ return
1236
+ }
1237
+ // ★ ?scope=groups —— 按【会话】分组(2026-10-24 主人要求)★
1238
+ // 为什么新增一个 scope 而不是改老的两个:
1239
+ // 老的 scope=all / scope=sid 都有人在用(客户端做标题链接、会话清单)✗
1240
+ // 改它们的返回形状 = 静默弄坏别处 ✓ 所以只**新增**一个分支 ✓
1241
+ // 数据来源:canvas-docs/.sessions/<sid>.json 里的 { activeId, ids } ✓
1242
+ // —— 这份归属关系早就有(09-http.js 顶部那套 sessState),
1243
+ // 只是一直没有"把全部会话一起返回"的接口 ✓
1244
+ // 返回:
1245
+ // groups [{ sid, activeId, ids: [...] }] 按 .json 的修改时间**倒序**(最近用过的在前)
1246
+ // canvases 全量元数据(id/title/path/updatedAt),客户端按 id 拼装
1247
+ // 规则:
1248
+ // · 跳过 type='scratch' ✓(AI 工作台,不进任何清单 ✓)
1249
+ // · 跳过 ids 全都不存在的空组 ✓(不显示空会话 ✓)
1250
+ if (url.searchParams.get('scope') === 'groups') {
1251
+ const dir = sessDirPath()
1252
+ let files = []
1253
+ try { files = nodefs.readdirSync(dir).filter((f) => /\.json$/i.test(f)) } catch (_) {}
1254
+ // ① 先全部读出来 ✓
1255
+ const entries = []
1256
+ for (const f of files) {
1257
+ let mt = 0
1258
+ let j = null
1259
+ try {
1260
+ mt = nodefs.statSync(joinPath(dir, f)).mtimeMs || 0
1261
+ j = JSON.parse(nodefs.readFileSync(joinPath(dir, f), 'utf8'))
1262
+ } catch (_) { continue }
1263
+ if (!j || !Array.isArray(j.ids)) continue
1264
+ entries.push({ f, key: String(f).replace(/\.json$/i, ''), mt, j, fp: joinPath(dir, f) })
1265
+ }
1266
+ // ② ★ 归一:每篇文档只留【一个】归属,并写回磁盘(2026-10-24)★
1267
+ // 主人症状:「我移动文档改分组,怎么乱变分组」
1268
+ // 根因(实测):25 篇里 10 篇同时挂在多个组 ✗(有一篇挂了 5 个组 ✓)
1269
+ // 而我上一版是**按文件修改时间去决定归属** ✗
1270
+ // → 每拖动一次,目标文件的时间戳变新 ✓
1271
+ // → 下一次读,其它重复文档的归属也跟着变 ✓ → 界面"乱变"✓✓
1272
+ // 修法:在**服务端**把重复彻底收敛掉 ✓ 规则稳定、与时间无关:
1273
+ // 真会话键优先(session-…)→ 其次键名升序(确定性 ✓ 不随写入漂移 ✓)
1274
+ // 归谁就写回磁盘:从其它组的 ids 里摘掉 ✓ 以后再也不重复 ✓
1275
+ const ranked = entries.slice().sort((a, b) => {
1276
+ const ar = /^session-/.test(a.key) ? 1 : 0
1277
+ const br = /^session-/.test(b.key) ? 1 : 0
1278
+ if (ar !== br) return br - ar // 真会话优先 ✓
1279
+ return a.key < b.key ? -1 : (a.key > b.key ? 1 : 0) // 其余按键名 ✓ 稳定 ✓
1280
+ })
1281
+ const ownerOf = {}
1282
+ for (const e of ranked) {
1283
+ for (const id of e.j.ids) { if (!ownerOf[id]) ownerOf[id] = e.key }
1284
+ }
1285
+ let normalized = 0
1286
+ for (const e of entries) {
1287
+ const keep = e.j.ids.filter((id) => ownerOf[id] === e.key)
1288
+ if (keep.length === e.j.ids.length) continue
1289
+ const nextActive = (e.j.activeId && keep.indexOf(e.j.activeId) >= 0) ? e.j.activeId : (keep[0] || null)
1290
+ try { nodefs.writeFileSync(e.fp, JSON.stringify({ activeId: nextActive, ids: keep }, null, 2)) } catch (_) {}
1291
+ sessStore.delete(e.key)
1292
+ e.j = { activeId: nextActive, ids: keep }
1293
+ normalized++
1294
+ }
1295
+ if (normalized) {
1296
+ console.log('[collab-canvas] 分组归一:' + normalized + ' 个组的重复归属已收敛(每篇文档只留一个归属)')
1297
+ }
1298
+ // ③ 组装分组(顺序只影响客户端默认排序 ✓ 客户端会自己按更新时间和当前会话重排 ✓)
1299
+ const withTime = []
1300
+ for (const e of entries) {
1301
+ const ids = e.j.ids.filter((id) => {
1302
+ const c = id ? canvases.get(id) : null
1303
+ return !!(c && c.type !== 'scratch')
1304
+ })
1305
+ if (!ids.length) continue // 空组不返回 ✓
1306
+ withTime.push({ mtime: e.mt, g: { sid: e.key, activeId: e.j.activeId || null, ids } })
1307
+ }
1308
+ withTime.sort((a, b) => b.mtime - a.mtime)
1309
+ const groupsOut = withTime.map((x) => x.g)
1310
+ const all = []
1311
+ canvases.forEach((c) => {
1312
+ if (!c || c.type === 'scratch') return
1313
+ all.push({ id: c.id, title: c.title, version: c.version, path: canvasMdPath(c), updatedAt: c.updatedAt || 0 })
1314
+ })
1315
+ httpJson(res, {
1316
+ ok: true,
1317
+ scope: 'groups',
1318
+ // real: 这个键是不是【真会话 id】(session-… 形式)。
1319
+ // 2026-10-24 之前客户端用的是 hash(会话名) 当键 ✗ → 那些组查不到会话名 ✓
1320
+ // 客户端会拿 hash(会话名) 反着撞一遍,能撞上的就当那个会话 ✓(见客户端注释)
1321
+ groups: groupsOut.map((g) => Object.assign({ real: /^session-/.test(g.sid) }, g)),
1322
+ canvases: all,
1323
+ activeId: activeId,
1324
+ })
1011
1325
  return
1012
1326
  }
1013
1327
  const sid = sessSafe(url.searchParams.get('sid'))
1014
1328
  lastBrowserSid = sid
1015
1329
  const st = sessState(sid)
1016
1330
  const arr = []
1017
- st.ids.forEach((id) => { const c = canvases.get(id); if (c) arr.push({ id: c.id, title: c.title, version: c.version, path: canvasMdPath(c) }) })
1018
- json(res, { ok: true, canvases: arr, activeId: st.activeId })
1331
+ st.ids.forEach((id) => { const c = canvases.get(id); if (c && c.type !== 'scratch') arr.push({ id: c.id, title: c.title, version: c.version, path: canvasMdPath(c) }) })
1332
+ httpJson(res, { ok: true, canvases: arr, activeId: st.activeId })
1019
1333
  }}))
1020
1334
  // GET /api/canvas/read?id=xxx —— 打开过就记入该会话清单
1021
1335
  ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/read', handler: async (req, res) => {
1022
1336
  const url = new URL(req.url, 'http://x')
1023
1337
  const id = url.searchParams.get('id')
1024
1338
  const c = id ? canvases.get(id) : (activeId ? canvases.get(activeId) : null)
1025
- if (!c) { error(res, 404, { ok: false, error: '画布不存在' }); return }
1026
- sessAdd(url.searchParams.get('sid'), c.id)
1027
- lastBrowserSid = sessSafe(url.searchParams.get('sid'))
1028
- json(res, { ok: true, id: c.id, title: c.title, content: c.content, version: c.version, path: canvasMdPath(c) })
1339
+ if (!c) { httpError(res, 404, { ok: false, error: '画布不存在' }); return }
1340
+ // ★ 2026-10-24 事故防线 ★
1341
+ // 「打开的文档都跑到旧记录.default 里去了,打开一个进去一个」
1342
+ // 根因:查看器那两处请求**没带 sid** ✗ → sessSafe(null) 退成 'default' ✗
1343
+ // → 每打开一篇就默默堆进 default ✓(和 12 篇老文档混在一起 ✓)
1344
+ // 客户端已修(补 sid)✓ 这里再加一道:
1345
+ // **sid 没带就别登记** ✓ —— 宁可让它落在「未归属」(看得见 ✓)
1346
+ // 也不要静默塞进一个假会话 ✓
1347
+ const sidRaw = url.searchParams.get('sid')
1348
+ if (sidRaw) {
1349
+ sessAddExclusive(sidRaw, c.id)
1350
+ lastBrowserSid = sessSafe(sidRaw)
1351
+ }
1352
+ httpJson(res, { ok: true, id: c.id, title: c.title, content: c.content, version: c.version, path: canvasMdPath(c), canUndo: canUndo(c), canRedo: canRedo(c) })
1029
1353
  }}))
1030
1354
  // GET /api/canvas/changes?since=N —— 画布端轮询「AI 刚改了哪儿」
1031
1355
  // 为什么用轮询而不是推送:客户端已经有现成的取数习惯(读文档、报日志都是普通请求),
@@ -1036,9 +1360,9 @@ function initCanvasHttpEndpoints(ctx, webServer) {
1036
1360
  const url = new URL(req.url, 'http://x')
1037
1361
  const raw = url.searchParams.get('since')
1038
1362
  const since = (raw === null || raw === '') ? 0 : parseInt(raw, 10)
1039
- if (isNaN(since)) { error(res, 400, { ok: false, error: 'since 必须是数字' }); return }
1363
+ if (isNaN(since)) { httpError(res, 400, { ok: false, error: 'since 必须是数字' }); return }
1040
1364
  const out = aiChanges.filter((x) => x.seq > since)
1041
- json(res, { ok: true, latest: aiChangeSeq, changes: out })
1365
+ httpJson(res, { ok: true, latest: aiChangeSeq, changes: out })
1042
1366
  }}))
1043
1367
  // POST /api/canvas/selref/pin —— 客户端报「你刚才划的是第几处」
1044
1368
  // 为什么需要:引用正文里**已经不写坐标了**(2026-09-15 主人要求对话里那张
@@ -1049,41 +1373,128 @@ function initCanvasHttpEndpoints(ctx, webServer) {
1049
1373
  // 实现在 13-selref-context.js(那边有 [[CCV-SELCTX]] 标记,测试切得出来)。
1050
1374
  ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/selref/pin', handler: async (req, res) => {
1051
1375
  try {
1052
- if (req.method !== 'POST') { error(res, 405, { ok: false, error: '只收 POST' }); return }
1053
- const body = await readBody(req)
1376
+ if (req.method !== 'POST') { httpError(res, 405, { ok: false, error: '只收 POST' }); return }
1377
+ const body = await httpReadJson(req)
1054
1378
  const taken = ccvPinAdd(body)
1055
- json(res, { ok: taken, n: ccvPins.length })
1056
- } catch (e) { error(res, 400, { ok: false, error: e.message }) }
1379
+ httpJson(res, { ok: taken, n: ccvPins.length })
1380
+ } catch (e) { httpError(res, 400, { ok: false, error: e.message }) }
1381
+ }}))
1382
+ // POST /api/canvas/scratch —— 开一个「选区编辑区」
1383
+ // 规格:canvas-docs/话布选区编辑区方案.md(第二、三、七节)
1384
+ // 客户端点「问 AI」时调它。body: { pinCode, docId, docPath, quote, ordinal, dupCount }
1385
+ //
1386
+ // 🔴 偏移由 **host 侧**按「引文 + 序数」解析,客户端不必把 DOM 偏移硬换算成
1387
+ // markdown 偏移 —— 那一步最容易错(渲染会吃掉 `**`、拆并段落)。
1388
+ // 口径与「位置主动注入」完全一致:**渲染前后文字的出现顺序不变**,
1389
+ // 所以两边数出来的"第几处"是同一个数(见 13-selref-context.js 的注释)。
1390
+ // 解析不出来(序数越界 / 引文找不到)→ offset 记 -1,**不猜**;
1391
+ // 实施步骤 3(拼接)遇到 -1 会报给主人,而不是硬拼。
1392
+ ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/scratch', handler: async (req, res) => {
1393
+ try {
1394
+ if (req.method !== 'POST') { httpError(res, 405, { ok: false, error: '只收 POST' }); return }
1395
+ const body = await httpReadJson(req)
1396
+ const quote = String(body.quote || '')
1397
+ if (!quote.trim()) { httpError(res, 400, { ok: false, error: '没有引文' }); return }
1398
+
1399
+ // 找主文档:优先 id,其次按真实路径(正斜杠、忽略大小写)
1400
+ let doc = body.docId ? canvases.get(body.docId) : null
1401
+ if (!doc && body.docPath) {
1402
+ const want = String(body.docPath).replace(/\\/g, '/').toLowerCase()
1403
+ canvases.forEach(function (c) {
1404
+ if (doc || !c || c.type === 'scratch') return
1405
+ const p = String(c.filePath || '').replace(/\\/g, '/').toLowerCase()
1406
+ if (p && p === want) doc = c
1407
+ })
1408
+ }
1409
+
1410
+ let offset = -1
1411
+ let dup = 0
1412
+ if (doc) {
1413
+ const hits = occIndexes(String(doc.content || ''), quote)
1414
+ dup = hits.length
1415
+ const ord = (typeof body.ordinal === 'number' && body.ordinal > 0) ? body.ordinal : 0
1416
+ if (ord > 0 && ord <= hits.length) offset = hits[ord - 1]
1417
+ else if (hits.length === 1) offset = hits[0]
1418
+ }
1419
+
1420
+ const c = createScratchCanvas({
1421
+ docId: doc ? doc.id : null,
1422
+ docPath: doc ? canvasMdPath(doc) : (body.docPath || null),
1423
+ offset: offset,
1424
+ length: quote.length,
1425
+ docVersion: doc ? doc.version : 0,
1426
+ ordinal: body.ordinal,
1427
+ dupCount: dup || body.dupCount,
1428
+ pinCode: body.pinCode,
1429
+ quote: quote,
1430
+ })
1431
+ ccvTrace('开了选区编辑区 ' + c.id + '(主文档=' + (doc ? doc.title : '没找到')
1432
+ + ',偏移=' + offset + ',全文 ' + dup + ' 处,序数=' + (body.ordinal || 0) + ')')
1433
+ httpJson(res, { ok: true, areaId: c.id, offset: offset, dupCount: dup })
1434
+ } catch (e) { httpError(res, 400, { ok: false, error: e.message }) }
1057
1435
  }}))
1058
1436
  // POST /api/canvas/write
1059
1437
  ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/write', handler: async (req, res) => {
1060
1438
  try {
1061
- const body = await readBody(req)
1439
+ const body = await httpReadJson(req)
1062
1440
  const id = body.id || activeId
1063
1441
  const c = id ? canvases.get(id) : null
1064
- if (!c) { error(res, 404, { ok: false, error: '画布不存在' }); return }
1442
+ if (!c) { httpError(res, 404, { ok: false, error: '画布不存在' }); return }
1065
1443
  const result = applyWrite(c, body.content, 'replace', body.knownVer, 'human')
1066
1444
  // ⚠️ 冲突必须显式回传。applyWrite 检出并发冲突时返回
1067
1445
  // { conflict, currentVersion, currentContent },此时 result.version 是 undefined;
1068
- // 若照原样 json({ ok: true, version: result.version }),
1446
+ // 若照原样 httpJson({ ok: true, version: result.version }),
1069
1447
  // undefined 会被 JSON.stringify 丢掉 → 响应变成 {"ok":true} ——
1070
1448
  // 冲突被伪装成成功,浏览器编辑器无从察觉,只能继续用旧内容写。
1071
1449
  if (result && result.conflict) {
1072
- json(res, { ok: false, conflict: true, currentVersion: result.currentVersion, currentContent: result.currentContent })
1450
+ httpJson(res, { ok: false, conflict: true, currentVersion: result.currentVersion, currentContent: result.currentContent })
1073
1451
  return
1074
1452
  }
1075
- json(res, { ok: true, version: result.version })
1076
- } catch (e) { error(res, 500, { ok: false, error: e.message }) }
1453
+ httpJson(res, { ok: true, version: result.version, canUndo: canUndo(c), canRedo: canRedo(c) })
1454
+ } catch (e) { httpError(res, 500, { ok: false, error: e.message }) }
1077
1455
  }}))
1456
+ // POST /api/canvas/undo | /api/canvas/redo
1457
+ //
1458
+ // 为什么必须加 HTTP 这一层:撤销栈(history/redoStack)和 canvas.undo RPC
1459
+ // **早就在了**,但那是走 harness.handle 的包级 RPC,而画布面板只走 HTTP
1460
+ // (/api/canvas/*)—— 两边根本不连通,于是"有撤销"这件事在界面上等于不存在
1461
+ // (2026-09-28 核对:lib/client.js 里 undo|redo 零命中,09-http.js 也一个端点都没有)。
1462
+ //
1463
+ // 语义:把 content 换成快照里的 before/after,version+1(客户端靠它同步 knownVer),
1464
+ // 结果落盘。栈空时回 { ok:true, noop:true } 而不是报错 —— 界面据此提示"没有可撤销的了"。
1465
+ function undoRedoEndpoint(path, fn) {
1466
+ ctx.effect(() => webServer.register({ kind: 'exact', path: path, handler: async (req, res) => {
1467
+ try {
1468
+ if (req.method !== 'POST') { httpError(res, 405, { ok: false, error: '只收 POST' }); return }
1469
+ const body = await httpReadJson(req)
1470
+ const id = body.id || activeId
1471
+ const c = id ? canvases.get(id) : null
1472
+ if (!c) { httpError(res, 404, { ok: false, error: '画布不存在' }); return }
1473
+ const r = fn(c)
1474
+ if (!r.ok) { httpError(res, 404, { ok: false, error: r.error }); return }
1475
+ // ⚠️ **立刻落盘**(2026-10-11 加,修"撤销有延迟")
1476
+ // 默认路径是 applyWrite → debouncedFlush(1500ms ✗),而查看器是**等文件变化**
1477
+ // 才刷新正文的 → 两段延迟叠起来手感很明显(主人报"撤回重做有延迟")。
1478
+ // 撤销/重做是**人的一次性动作**,没有连打的场景 —— 不值得为它省一次写盘 ✓
1479
+ // 所以这里跳过 debounce 直接 flush(写盘量很小,就一篇文档 ✓)。
1480
+ try { await flushDirty() } catch (_) {}
1481
+ httpJson(res, { ok: true, noop: !!r.noop, content: r.content, version: r.version, source: r.source || null, canUndo: canUndo(c), canRedo: canRedo(c) })
1482
+ } catch (e) { httpError(res, 500, { ok: false, error: e.message }) }
1483
+ }}))
1484
+ }
1485
+ undoRedoEndpoint('/api/canvas/undo', undoCanvas)
1486
+ undoRedoEndpoint('/api/canvas/redo', redoCanvas)
1078
1487
  // POST /api/canvas/create?sid=xxx —— 新建即记入当前会话清单
1079
1488
  ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/create', handler: async (req, res) => {
1080
1489
  try {
1081
1490
  const url = new URL(req.url, 'http://x')
1082
- const body = await readBody(req)
1491
+ const body = await httpReadJson(req)
1083
1492
  const c = createCanvasDoc(body.title || '新话布')
1084
- sessAdd(url.searchParams.get('sid'), c.id)
1085
- json(res, { ok: true, id: c.id, title: c.title })
1086
- } catch (e) { error(res, 500, { ok: false, error: e.message }) }
1493
+ // 同上:sid 没带就别登记 ✓(别塞进假会话 'default' ✗)
1494
+ const sidCreate = url.searchParams.get('sid')
1495
+ if (sidCreate) sessAddExclusive(sidCreate, c.id)
1496
+ httpJson(res, { ok: true, id: c.id, title: c.title })
1497
+ } catch (e) { httpError(res, 500, { ok: false, error: e.message }) }
1087
1498
  }}))
1088
1499
  // ─── 客户端诊断日志(排障用)─────────────────────────
1089
1500
  // 环境里用户开不了浏览器控制台,客户端把关键判定点 POST 到这里,
@@ -1106,19 +1517,61 @@ function initCanvasHttpEndpoints(ctx, webServer) {
1106
1517
  ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/log', handler: async (req, res) => {
1107
1518
  try {
1108
1519
  if (req.method === 'POST') {
1109
- const body = await readBody(req)
1520
+ const body = await httpReadJson(req)
1110
1521
  const lines = Array.isArray(body.lines) ? body.lines : [String(body.line || '')]
1111
1522
  lines.forEach(function (l) { _dbg.push(ts() + ' ' + String(l)) })
1112
1523
  if (_dbg.length > DBG_MAX) _dbg = _dbg.slice(_dbg.length - DBG_MAX)
1113
1524
  dbgWrite()
1114
- json(res, { ok: true, n: _dbg.length, file: DEBUG_LOG })
1525
+ httpJson(res, { ok: true, n: _dbg.length, file: DEBUG_LOG })
1115
1526
  } else {
1116
1527
  res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
1117
1528
  res.end(_dbg.join('\n') + '\n')
1118
1529
  }
1119
- } catch (e) { error(res, 500, { ok: false, error: e.message }) }
1530
+ } catch (e) { httpError(res, 500, { ok: false, error: e.message }) }
1531
+ }}))
1532
+ // ─── POST /api/canvas/move-group —— 把一篇文档挪到另一个分组 ──────────
1533
+ // 2026-10-24 主人要求「我要能自由拖动改分组」✓
1534
+ // 分组**只是索引** ✓ 这里只动 .sessions/*.json 里的 id 数组 ✓
1535
+ // **绝不碰 md 文件、绝不碰 filePath、绝不碰内容** ✓(主人确认过这条 ✓)
1536
+ // body: { id, toSid } toSid 为空字符串 = 移出所有组(回到「未归属」)✓
1537
+ // ⚠️ 改完必须清 sessStore 缓存 ✗ 否则内存里还是旧清单 ✓
1538
+ ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/move-group', handler: async (req, res) => {
1539
+ try {
1540
+ if (req.method !== 'POST') { httpError(res, 405, { ok: false, error: '只收 POST' }); return }
1541
+ const body = await httpReadJson(req)
1542
+ const id = String((body && body.id) || '').trim()
1543
+ const toSid = String((body && body.toSid) || '').trim()
1544
+ if (!id) { httpError(res, 400, { ok: false, error: '缺 id' }); return }
1545
+ const c = canvases.get(id)
1546
+ if (!c) { httpError(res, 404, { ok: false, error: '文档不存在' }); return }
1547
+ if (c.type === 'scratch') { httpError(res, 400, { ok: false, error: 'scratch 不进任何分组' }); return }
1548
+ const dir = sessDirPath()
1549
+ let files = []
1550
+ try { files = nodefs.readdirSync(dir).filter((f) => /\.json$/i.test(f)) } catch (_) {}
1551
+ let removed = 0
1552
+ for (const f of files) {
1553
+ const fp = joinPath(dir, f)
1554
+ let j = null
1555
+ try { j = JSON.parse(nodefs.readFileSync(fp, 'utf8')) } catch (_) { continue }
1556
+ if (!j || !Array.isArray(j.ids) || j.ids.indexOf(id) < 0) continue
1557
+ j.ids = j.ids.filter((x) => x !== id)
1558
+ const nextActive = (j.activeId === id) ? (j.ids[0] || null) : (j.activeId || null)
1559
+ try { nodefs.writeFileSync(fp, JSON.stringify({ activeId: nextActive, ids: j.ids }, null, 2)) } catch (_) {}
1560
+ sessStore.delete(String(f).replace(/\.json$/i, ''))
1561
+ removed++
1562
+ }
1563
+ let targetSid = ''
1564
+ if (toSid) {
1565
+ targetSid = sessSafe(toSid)
1566
+ const st = sessLoad(targetSid) // 从盘读,别用可能过期的缓存 ✓
1567
+ if (st.ids.indexOf(id) < 0) st.ids.push(id)
1568
+ const keepActive = (st.activeId && st.ids.indexOf(st.activeId) >= 0) ? st.activeId : id
1569
+ sessPersist(targetSid, { activeId: keepActive, ids: st.ids })
1570
+ sessStore.delete(targetSid)
1571
+ }
1572
+ httpJson(res, { ok: true, id, removed, toSid: targetSid })
1573
+ } catch (e) { httpError(res, 500, { ok: false, error: e.message }) }
1120
1574
  }}))
1121
- // ─── 打开文档所在文件夹 ─────────────────────────────
1122
1575
  // 客户端「📁」按钮调用;host 是 Node 主进程,用 explorer/open/xdg-open
1123
1576
  // 在系统文件管理器里打开当前画布文档所在的目录。
1124
1577
  // 注意:不要用 fs.resolve —— 它返回的是沙箱内部标记,不是文件系统路径,
@@ -1167,7 +1620,7 @@ function initCanvasHttpEndpoints(ctx, webServer) {
1167
1620
  dbgWrite()
1168
1621
  }, 1200)
1169
1622
  console.log('[collab-canvas] open-folder', JSON.stringify(info))
1170
- json(res, { ok: true, dir: dir, raw: raw, exists: info.exists, pid: info.pid, strategy: info.strategy })
1623
+ httpJson(res, { ok: true, dir: dir, raw: raw, exists: info.exists, pid: info.pid, strategy: info.strategy })
1171
1624
  }}))
1172
1625
  console.log('[collab-canvas] HTTP endpoints registered: /api/canvas/* (debug log -> ' + DEBUG_LOG + ')')
1173
1626
  }
@@ -1185,28 +1638,6 @@ function initCanvasHttpEndpoints(ctx, webServer) {
1185
1638
  // 依赖同作用域:docsDirBase, docsDir, canvases, createCanvasDoc, applyWrite,
1186
1639
  // saveCanvas, persistMeta, nodefs, path(均由其他模块提供)
1187
1640
  function initCanvasExtraEndpoints(ctx, webServer) {
1188
- // JSON 响应辅助(与 09-http 同款,本文件自包含)
1189
- function json(res, data) {
1190
- res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' })
1191
- res.end(JSON.stringify(data))
1192
- }
1193
- function error(res, status, data) {
1194
- res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
1195
- res.end(JSON.stringify(data))
1196
- }
1197
- function readBody(req, limit) {
1198
- return new Promise((resolve, reject) => {
1199
- const chunks = []
1200
- let size = 0
1201
- req.on('data', (c) => {
1202
- size += c.length
1203
- if (limit && size > limit) { reject(new Error('文件超过大小上限')); try { req.destroy() } catch (_) {} ; return }
1204
- chunks.push(c)
1205
- })
1206
- req.on('end', () => resolve(Buffer.concat(chunks)))
1207
- req.on('error', reject)
1208
- })
1209
- }
1210
1641
 
1211
1642
  // 会话工作区目录(@ 文件选择器索引的目录)
1212
1643
  function sessionWorkspaceDir() {
@@ -1218,7 +1649,7 @@ function initCanvasExtraEndpoints(ctx, webServer) {
1218
1649
  const cwd = holder && holder.cwd
1219
1650
  if (typeof cwd === 'string' && cwd.length > 2) return cwd
1220
1651
  }
1221
- } catch (e) { console.error('[collab-canvas] session-cwd probe failed:', e && e.message) }
1652
+ } catch (e) { console.httpError('[collab-canvas] session-cwd probe failed:', e && e.message) }
1222
1653
  return docsDirBase()
1223
1654
  }
1224
1655
 
@@ -1238,13 +1669,13 @@ function initCanvasExtraEndpoints(ctx, webServer) {
1238
1669
  const j = JSON.parse(nodefs.readFileSync(f, 'utf8'))
1239
1670
  if (Array.isArray(j.folders)) return j.folders.filter(p => typeof p === 'string' && path.isAbsolute(p))
1240
1671
  }
1241
- } catch (e) { console.error('[collab-canvas] registry config read failed:', e && e.message) }
1672
+ } catch (e) { console.httpError('[collab-canvas] registry config read failed:', e && e.message) }
1242
1673
  return []
1243
1674
  }
1244
1675
  function regSaveConfig(folders) {
1245
1676
  try {
1246
1677
  nodefs.writeFileSync(REG_CONFIG_FILE(), JSON.stringify({ folders: folders }, null, 2))
1247
- } catch (e) { console.error('[collab-canvas] registry config write failed:', e && e.message) }
1678
+ } catch (e) { console.httpError('[collab-canvas] registry config write failed:', e && e.message) }
1248
1679
  }
1249
1680
  function regScanFolder(folder) {
1250
1681
  const map = new Map()
@@ -1288,7 +1719,7 @@ function initCanvasExtraEndpoints(ctx, webServer) {
1288
1719
  regTotal = 0
1289
1720
  regIndex.forEach(m => { m.forEach(arr => { regTotal += arr.length }) })
1290
1721
  if (changed) console.log('[collab-canvas] registry scanned', folder, '->', n, 'files')
1291
- } catch (e) { console.error('[collab-canvas] registry scan failed:', folder, e && e.message) }
1722
+ } catch (e) { console.httpError('[collab-canvas] registry scan failed:', folder, e && e.message) }
1292
1723
  }
1293
1724
  const regLastSig = new Map() // folder -> 上次索引签名(文件数+路径集),无变化的重扫静默处理
1294
1725
  const regRescanTimers = new Map()
@@ -1303,7 +1734,7 @@ function initCanvasExtraEndpoints(ctx, webServer) {
1303
1734
  const w = nodefs.watch(folder, { recursive: true }, function () { regScheduleRescan(folder) })
1304
1735
  w.on('error', function () {})
1305
1736
  regWatchers.set(folder, w)
1306
- } catch (e) { console.error('[collab-canvas] registry watch failed:', folder, e && e.message) }
1737
+ } catch (e) { console.httpError('[collab-canvas] registry watch failed:', folder, e && e.message) }
1307
1738
  }
1308
1739
  function regUnwatch(folder) {
1309
1740
  const w = regWatchers.get(folder)
@@ -1330,10 +1761,10 @@ function initCanvasExtraEndpoints(ctx, webServer) {
1330
1761
  ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/registry', handler: async (req, res) => {
1331
1762
  try {
1332
1763
  if (req.method === 'POST') {
1333
- const body = await readBody(req)
1764
+ const body = await httpReadBody(req)
1334
1765
  const folders = Array.isArray(body.folders) ? body.folders.map(s => String(s).trim()).filter(Boolean) : []
1335
1766
  const invalid = folders.filter(f => !path.isAbsolute(f) || !nodefs.existsSync(f) || !nodefs.statSync(f).isDirectory())
1336
- if (invalid.length) { error(res, 400, { ok: false, error: '以下路径无效或不是文件夹: ' + invalid.join(', ') }); return }
1767
+ if (invalid.length) { httpError(res, 400, { ok: false, error: '以下路径无效或不是文件夹: ' + invalid.join(', ') }); return }
1337
1768
  const uniq = Array.from(new Set(folders))
1338
1769
  regFolders.length = 0
1339
1770
  regFolders.push(...uniq)
@@ -1344,11 +1775,11 @@ function initCanvasExtraEndpoints(ctx, webServer) {
1344
1775
  regWatch(f)
1345
1776
  setTimeout(function () { regRescanFolder(f) }, 0)
1346
1777
  })
1347
- json(res, { ok: true, folders: regFolders.slice(), files: regTotal, note: '索引构建中' })
1778
+ httpJson(res, { ok: true, folders: regFolders.slice(), files: regTotal, note: '索引构建中' })
1348
1779
  return
1349
1780
  }
1350
- json(res, { ok: true, folders: regFolders.slice(), files: regTotal })
1351
- } catch (e) { error(res, 500, { ok: false, error: e.message }) }
1781
+ httpJson(res, { ok: true, folders: regFolders.slice(), files: regTotal })
1782
+ } catch (e) { httpError(res, 500, { ok: false, error: e.message }) }
1352
1783
  }}))
1353
1784
  // GET /api/canvas/locate?name=xxx —— 按文件名查登记索引,返回真实路径
1354
1785
  //(rel = 会话工作区相对路径;docsRel = 画布文档目录相对路径,形如 canvas-docs/xxx.md)
@@ -1372,8 +1803,8 @@ function initCanvasExtraEndpoints(ctx, webServer) {
1372
1803
  matches.push({ path: norm, rel: rel, docsRel: docsRel })
1373
1804
  })
1374
1805
  })
1375
- json(res, { ok: true, name: name, matches: matches, folders: regFolders.length })
1376
- } catch (e) { error(res, 500, { ok: false, error: e.message }) }
1806
+ httpJson(res, { ok: true, name: name, matches: matches, folders: regFolders.length })
1807
+ } catch (e) { httpError(res, 500, { ok: false, error: e.message }) }
1377
1808
  }}))
1378
1809
  // GET /api/canvas/load-doc?rel=canvas-docs/xxx.md —— 把 canvas-docs 里的 md
1379
1810
  // 注册为话布(按标题找已有,找不到新建)并返回画布 id——即 canvas_load 的最小实现
@@ -1381,9 +1812,9 @@ function initCanvasExtraEndpoints(ctx, webServer) {
1381
1812
  try {
1382
1813
  const url = new URL(req.url, 'http://x')
1383
1814
  const rel = String(url.searchParams.get('rel') || '')
1384
- if (!/^canvas-docs\/[^\\/]+\.md$/i.test(rel)) { error(res, 400, { ok: false, error: '仅支持 canvas-docs/ 下的 .md 文件' }); return }
1815
+ if (!/^canvas-docs\/[^\\/]+\.md$/i.test(rel)) { httpError(res, 400, { ok: false, error: '仅支持 canvas-docs/ 下的 .md 文件' }); return }
1385
1816
  const full = path.join(docsDir(), rel.slice('canvas-docs/'.length))
1386
- if (!nodefs.existsSync(full)) { error(res, 404, { ok: false, error: '文件不存在' }); return }
1817
+ if (!nodefs.existsSync(full)) { httpError(res, 404, { ok: false, error: '文件不存在' }); return }
1387
1818
  const content = nodefs.readFileSync(full, 'utf8')
1388
1819
  const title = rel.split('/').pop().replace(/\.(md|markdown)$/i, '')
1389
1820
  let c = null
@@ -1403,12 +1834,12 @@ function initCanvasExtraEndpoints(ctx, webServer) {
1403
1834
  if (!c) {
1404
1835
  c = createCanvasDoc(title)
1405
1836
  applyWrite(c, content, 'replace', undefined, 'human')
1406
- try { await saveCanvas(c) } catch (e) { console.error('[collab-canvas] load-doc save failed:', e && e.message) }
1837
+ try { await saveCanvas(c) } catch (e) { console.httpError('[collab-canvas] load-doc save failed:', e && e.message) }
1407
1838
  created = true
1408
1839
  }
1409
- sessAdd(url.searchParams.get('sid'), c.id) // 登记即记入当前会话清单
1410
- json(res, { ok: true, id: c.id, title: c.title, created: created })
1411
- } catch (e) { error(res, 500, { ok: false, error: e.message }) }
1840
+ sessAddExclusive(url.searchParams.get('sid'), c.id) // 登记即记入当前会话清单
1841
+ httpJson(res, { ok: true, id: c.id, title: c.title, created: created })
1842
+ } catch (e) { httpError(res, 500, { ok: false, error: e.message }) }
1412
1843
  }}))
1413
1844
  // ─── 白名单:本地文档「就地」登记为话布 ───────────────────────────
1414
1845
  // 允许范围 = 会话工作区 ∪ 画布目录 ∪ 文件登记表里登记的文件夹。
@@ -1434,23 +1865,23 @@ function initCanvasExtraEndpoints(ctx, webServer) {
1434
1865
  try {
1435
1866
  const url = new URL(req.url, 'http://x')
1436
1867
  const raw = String(url.searchParams.get('path') || '').trim()
1437
- if (!raw) { error(res, 400, { ok: false, error: '缺少 path 参数' }); return }
1438
- if (!/\.(md|markdown|txt|text)$/i.test(raw)) { error(res, 400, { ok: false, error: '仅支持 .md/.markdown/.txt' }); return }
1868
+ if (!raw) { httpError(res, 400, { ok: false, error: '缺少 path 参数' }); return }
1869
+ if (!/\.(md|markdown|txt|text)$/i.test(raw)) { httpError(res, 400, { ok: false, error: '仅支持 .md/.markdown/.txt' }); return }
1439
1870
  const abs = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(sessionWorkspaceDir(), raw)
1440
1871
  if (!pathAllowedInTarget(abs)) {
1441
- error(res, 403, { ok: false, error: '路径不在允许范围内(会话工作区 / 画布目录 / 已登记文件夹)' })
1872
+ httpError(res, 403, { ok: false, error: '路径不在允许范围内(会话工作区 / 画布目录 / 已登记文件夹)' })
1442
1873
  return
1443
1874
  }
1444
- if (!nodefs.existsSync(abs)) { error(res, 404, { ok: false, error: '文件不存在' }); return }
1875
+ if (!nodefs.existsSync(abs)) { httpError(res, 404, { ok: false, error: '文件不存在' }); return }
1445
1876
  let c = null
1446
1877
  canvases.forEach(function (x) {
1447
1878
  if (!c && x.filePath) { try { if (path.resolve(x.filePath) === abs) c = x } catch (_) {} }
1448
1879
  })
1449
1880
  let created = false
1450
1881
  if (!c) { c = await loadFileDoc(abs); created = true }
1451
- sessAdd(url.searchParams.get('sid'), c.id)
1452
- json(res, { ok: true, id: c.id, title: c.title, filePath: c.filePath, created: created })
1453
- } catch (e) { error(res, 500, { ok: false, error: e.message }) }
1882
+ sessAddExclusive(url.searchParams.get('sid'), c.id)
1883
+ httpJson(res, { ok: true, id: c.id, title: c.title, filePath: c.filePath, created: created })
1884
+ } catch (e) { httpError(res, 500, { ok: false, error: e.message }) }
1454
1885
  }}))
1455
1886
  // GET /api/canvas/delete?id=xxx&sid=xxx —— 从当前会话清单移除画布;
1456
1887
  // 若其他会话清单仍引用该画布则只摘引用保留文件,无人引用才连 md 一起删
@@ -1460,7 +1891,7 @@ function initCanvasExtraEndpoints(ctx, webServer) {
1460
1891
  const id = url.searchParams.get('id')
1461
1892
  const sid = sessSafe(url.searchParams.get('sid'))
1462
1893
  const c = id ? canvases.get(id) : null
1463
- if (!c) { error(res, 404, { ok: false, error: '画布不存在' }); return }
1894
+ if (!c) { httpError(res, 404, { ok: false, error: '画布不存在' }); return }
1464
1895
  sessRemove(sid, id)
1465
1896
  let referenced = false
1466
1897
  try {
@@ -1478,29 +1909,29 @@ function initCanvasExtraEndpoints(ctx, webServer) {
1478
1909
  if (activeId === id) activeId = null
1479
1910
  try {
1480
1911
  if (c.filePath && nodefs.existsSync(c.filePath)) nodefs.unlinkSync(c.filePath)
1481
- } catch (e) { console.error('[collab-canvas] delete file failed:', e && e.message) }
1912
+ } catch (e) { console.httpError('[collab-canvas] delete file failed:', e && e.message) }
1482
1913
  persistMeta()
1483
1914
  }
1484
- json(res, { ok: true, id: id, title: c.title, fileDeleted: !referenced })
1485
- } catch (e) { error(res, 500, { ok: false, error: e.message }) }
1915
+ httpJson(res, { ok: true, id: id, title: c.title, fileDeleted: !referenced })
1916
+ } catch (e) { httpError(res, 500, { ok: false, error: e.message }) }
1486
1917
  }}))
1487
1918
  // POST /api/canvas/import-doc?name=xxx —— 外部文档拖入/上传:复制到会话工作区
1488
1919
  // 的 uploads/ 文件夹(@ 文件选择器可索引到),返回相对路径供 @ 引用
1489
1920
  ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/import-doc', handler: async (req, res) => {
1490
1921
  try {
1491
- if (req.method !== 'POST') { error(res, 405, { ok: false, error: 'POST only' }); return }
1922
+ if (req.method !== 'POST') { httpError(res, 405, { ok: false, error: 'POST only' }); return }
1492
1923
  const url = new URL(req.url, 'http://x')
1493
1924
  let name = url.searchParams.get('name') || 'doc.md'
1494
1925
  name = String(name).replace(/[\\/:*?"<>|\r\n]/g, '_').trim() || 'doc.md'
1495
- const body = await readBody(req, UPLOAD_LIMIT)
1496
- if (!body || !body.length) { error(res, 400, { ok: false, error: '空文件' }); return }
1926
+ const body = await httpReadBody(req, UPLOAD_LIMIT)
1927
+ if (!body || !body.length) { httpError(res, 400, { ok: false, error: '空文件' }); return }
1497
1928
  const dir = path.join(sessionWorkspaceDir(), 'uploads')
1498
1929
  try { nodefs.mkdirSync(dir, { recursive: true }) } catch (e) {}
1499
1930
  const fname = Date.now() + '-' + Math.random().toString(36).slice(2, 6) + '-' + name
1500
1931
  nodefs.writeFileSync(path.join(dir, fname), body)
1501
1932
  console.log('[collab-canvas] import-doc:', fname, body.length + 'B')
1502
- json(res, { ok: true, path: 'uploads/' + fname, name: name, size: body.length })
1503
- } catch (e) { error(res, 500, { ok: false, error: e.message }) }
1933
+ httpJson(res, { ok: true, path: 'uploads/' + fname, name: name, size: body.length })
1934
+ } catch (e) { httpError(res, 500, { ok: false, error: e.message }) }
1504
1935
  }}))
1505
1936
  // GET /api/canvas/open-upload?name=xxx —— 把拖入上传的文档变成/打开话布:
1506
1937
  // 已有同名(去掉时间戳前缀后的标题,含/不含 .md 扩展名)话布 → 直接返回其 id;
@@ -1509,11 +1940,11 @@ function initCanvasExtraEndpoints(ctx, webServer) {
1509
1940
  try {
1510
1941
  const url = new URL(req.url, 'http://x')
1511
1942
  const fname = String(url.searchParams.get('name') || '')
1512
- if (!/^\d{13}-[a-z0-9]{2,8}-[^\r\n\\/:"*?<>|]{1,120}$/i.test(fname)) { error(res, 400, { ok: false, error: '非法文件名' }); return }
1943
+ if (!/^\d{13}-[a-z0-9]{2,8}-[^\r\n\\/:"*?<>|]{1,120}$/i.test(fname)) { httpError(res, 400, { ok: false, error: '非法文件名' }); return }
1513
1944
  const upDir = path.join(sessionWorkspaceDir(), 'uploads')
1514
1945
  const full = path.join(upDir, fname)
1515
- if (path.resolve(full).indexOf(path.resolve(upDir) + path.sep) !== 0) { error(res, 403, { ok: false, error: '非法路径' }); return }
1516
- if (!nodefs.existsSync(full)) { error(res, 404, { ok: false, error: '文件不存在' }); return }
1946
+ if (path.resolve(full).indexOf(path.resolve(upDir) + path.sep) !== 0) { httpError(res, 403, { ok: false, error: '非法路径' }); return }
1947
+ if (!nodefs.existsSync(full)) { httpError(res, 404, { ok: false, error: '文件不存在' }); return }
1517
1948
  const content = nodefs.readFileSync(full, 'utf8')
1518
1949
  const title = fname.replace(/^\d{13}-[a-z0-9]{2,8}-/i, '')
1519
1950
  const baseTitle = title.replace(/\.(md|markdown)$/i, '')
@@ -1528,12 +1959,12 @@ function initCanvasExtraEndpoints(ctx, webServer) {
1528
1959
  if (!c) {
1529
1960
  c = createCanvasDoc(title)
1530
1961
  applyWrite(c, content, 'replace', undefined, 'human')
1531
- try { await saveCanvas(c) } catch (e) { console.error('[collab-canvas] open-upload save failed:', e && e.message) }
1962
+ try { await saveCanvas(c) } catch (e) { console.httpError('[collab-canvas] open-upload save failed:', e && e.message) }
1532
1963
  created = true
1533
1964
  }
1534
- sessAdd(url.searchParams.get('sid'), c.id) // 导入即记入当前会话清单
1535
- json(res, { ok: true, id: c.id, title: c.title, created: created, matched: matched })
1536
- } catch (e) { error(res, 500, { ok: false, error: e.message }) }
1965
+ sessAddExclusive(url.searchParams.get('sid'), c.id) // 导入即记入当前会话清单
1966
+ httpJson(res, { ok: true, id: c.id, title: c.title, created: created, matched: matched })
1967
+ } catch (e) { httpError(res, 500, { ok: false, error: e.message }) }
1537
1968
  }}))
1538
1969
  // 画布附件与独立模块源码服务(file / docdrop.js / doclink.js)
1539
1970
  function canvasDocDir() {
@@ -1560,10 +1991,10 @@ function initCanvasExtraEndpoints(ctx, webServer) {
1560
1991
  const full = path.resolve(base, rel)
1561
1992
  const norm = (p) => (process.platform === 'win32' ? p.toLowerCase() : p)
1562
1993
  if (norm(full) !== norm(base) && norm(full).indexOf(norm(base) + path.sep) !== 0) {
1563
- error(res, 403, { ok: false, error: '非法路径' }); return
1994
+ httpError(res, 403, { ok: false, error: '非法路径' }); return
1564
1995
  }
1565
1996
  if (!nodefs.existsSync(full) || !nodefs.statSync(full).isFile()) {
1566
- error(res, 404, { ok: false, error: '文件不存在' }); return
1997
+ httpError(res, 404, { ok: false, error: '文件不存在' }); return
1567
1998
  }
1568
1999
  const ext = (full.split('.').pop() || '').toLowerCase()
1569
2000
  const mime = MIME_MAP[ext] || 'application/octet-stream'
@@ -1577,24 +2008,24 @@ function initCanvasExtraEndpoints(ctx, webServer) {
1577
2008
  'Cache-Control': 'no-cache',
1578
2009
  })
1579
2010
  res.end(buf)
1580
- } catch (e) { error(res, 500, { ok: false, error: e.message }) }
2011
+ } catch (e) { httpError(res, 500, { ok: false, error: e.message }) }
1581
2012
  }}))
1582
2013
  // POST /api/canvas/upload?name=xxx —— 画布附件上传(图片粘贴等),存 canvas-docs/attachments/
1583
2014
  ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/upload', handler: async (req, res) => {
1584
2015
  try {
1585
- if (req.method !== 'POST') { error(res, 405, { ok: false, error: 'POST only' }); return }
2016
+ if (req.method !== 'POST') { httpError(res, 405, { ok: false, error: 'POST only' }); return }
1586
2017
  const url = new URL(req.url, 'http://x')
1587
2018
  let name = url.searchParams.get('name') || 'file.bin'
1588
2019
  name = String(name).replace(/[\\/:*?"<>|\r\n]/g, '_').trim() || 'file.bin'
1589
2020
  const body = await readRawBody(req, UPLOAD_LIMIT)
1590
- if (!body || !body.length) { error(res, 400, { ok: false, error: '空文件' }); return }
2021
+ if (!body || !body.length) { httpError(res, 400, { ok: false, error: '空文件' }); return }
1591
2022
  const dir = path.join(canvasDocDir(), 'attachments')
1592
2023
  try { nodefs.mkdirSync(dir, { recursive: true }) } catch (e) {}
1593
2024
  const fname = Date.now() + '-' + name
1594
2025
  nodefs.writeFileSync(path.join(dir, fname), body)
1595
2026
  console.log('[collab-canvas] upload:', fname, body.length + 'B')
1596
- json(res, { ok: true, path: 'attachments/' + fname, name: name, size: body.length })
1597
- } catch (e) { error(res, 500, { ok: false, error: e.message }) }
2027
+ httpJson(res, { ok: true, path: 'attachments/' + fname, name: name, size: body.length })
2028
+ } catch (e) { httpError(res, 500, { ok: false, error: e.message }) }
1598
2029
  }}))
1599
2030
  function readRawBody(req, limit) {
1600
2031
  return new Promise((resolve, reject) => {
@@ -1609,18 +2040,20 @@ function initCanvasExtraEndpoints(ctx, webServer) {
1609
2040
  req.on('error', reject)
1610
2041
  })
1611
2042
  }
1612
- // GET /api/canvas/docdrop.js、doclink.js、selref.js —— 提供独立客户端模块源码
2043
+ // GET /api/canvas/selref.js、viewer-md.js、doc-model.js —— 提供独立客户端模块源码
1613
2044
  //(client 入口动态挂载;每次读盘,改完刷新页面即生效,无需重启)。
1614
2045
  // 注意:运行时路径相对于部署目录的 dist/host.js 解析(../lib/…)
1615
2046
  // ⚠️ 这个名单是服务端常量,新增一项需要重启 dsh web 才认得出该端点。
1616
- ;['docdrop.js', 'doclink.js', 'selref.js'].forEach(function (modFile) {
2047
+ // ⚠️ docdrop.js / doclink.js 于 2026-10-11 随画布面板一起删除(面板专属 ✗)。
2048
+ // ⚠️ doc-model.js 于 2026-10-15 加入(A4:按块转换要用它 ✓ 必须排在 viewer-md 之前 ✓)
2049
+ ;['ccv-logic.js', 'doc-model.js', 'selref.js', 'viewer-md.js'].forEach(function (modFile) {
1617
2050
  ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/' + modFile, handler: async (req, res) => {
1618
2051
  try {
1619
2052
  const srcPath = new URL('../lib/' + modFile, import.meta.url)
1620
2053
  const src = nodefs.readFileSync(srcPath, 'utf8')
1621
2054
  res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-cache' })
1622
2055
  res.end(src)
1623
- } catch (e) { error(res, 500, { ok: false, error: e.message }) }
2056
+ } catch (e) { httpError(res, 500, { ok: false, error: e.message }) }
1624
2057
  }}))
1625
2058
  })
1626
2059
 
@@ -1633,7 +2066,7 @@ function initCanvasExtraEndpoints(ctx, webServer) {
1633
2066
  }
1634
2067
  regSeedIfEmpty()
1635
2068
  console.log('[collab-canvas] file registry:', regFolders.length ? regFolders.join(' | ') : '(未登记文件夹)', '索引', regTotal, '文件')
1636
- } catch (e) { console.error('[collab-canvas] registry init failed:', e && e.message) }
2069
+ } catch (e) { console.httpError('[collab-canvas] registry init failed:', e && e.message) }
1637
2070
  }, 0)
1638
2071
  }
1639
2072
 
@@ -1876,28 +2309,45 @@ function ccvPinAdd(p) {
1876
2309
  }
1877
2310
  }
1878
2311
 
1879
- // 找出这条引用对应的上报记录。取**最新**的一条 —— 用户的操作顺序天然是
1880
- // 「先划选、再发送」,所以最新那条就是这次的。
1881
- // ⚠️ 已知边界:同一段原文连着划两次、却只发出第一条引用时,会取到后一条的序数。
1882
- // 罕见,且注入文案里保留了每处的上下文供核对,不会静默改错。
2312
+ // 找出这条引用对应的上报记录。
2313
+ //
2314
+ // 🔴 2026-10-04 修正:**不再无脑取最新**,改成取「从后往前第 remaining 条」。
2315
+ //
2316
+ // 为什么:一条消息里出现两条**文字完全相同**的引用时(同一篇里同一个词选两处),
2317
+ // 无脑取最新会让两条引用抢同一条上报、最后**正好互换**:
2318
+ // 划第 5 处 → 上报 A(序数 5) 消息里第 1 条引用 → 从最新找 → 拿到 B(10) ✗
2319
+ // 划第 10 处 → 上报 B(序数 10) 消息里第 2 条引用 → B 用过了 → 拿到 A(5) ✗
2320
+ // 主人 2026-10-04 实测的「改反了」就是这个 —— 而且是**静默改错**,界面上看不出来。
2321
+ // (旧注释把它判成"罕见、不会静默改错",判错了。)
2322
+ //
2323
+ // remaining = 这条消息里与它**文字相同**的引用还剩几条(含自己):
2324
+ // · 只有 1 条引用(哪怕手上有历史上报)→ remaining=1 → 取最后一条 = 最新 ✓
2325
+ // 保持原来的正确行为:重新引用同一段时,不会取到上次那条旧上报。
2326
+ // · 两条相同 → 第 1 条取倒数第 2 条、第 2 条取最后一条 ✓ 两条各归各位。
1883
2327
  //
1884
2328
  // 🔴 `used` 是**本次拼装里已经用掉的上报**:一条消息里引用了两段不同的原文、
1885
2329
  // 但它们被解析成同一个 `q`(比如原文本身含「」被截短)时,两条会抢同一条上报
1886
2330
  // → 第二条拿到第一条的序数 → 改错地方。所以用过的不能再给下一条用。
1887
- function ccvPinFind(path, quote, used) {
2331
+ function ccvPinFind(path, quote, used, remaining) {
1888
2332
  var want = ccvNormPathForRef(path)
1889
2333
  var q = String(quote || '')
1890
2334
  if (!q) return null
1891
- for (var i = ccvPins.length - 1; i >= 0; i--) {
2335
+ // 先按**时间顺序**(旧 → 新)把所有匹配且没用过的收集起来,再按 remaining 从后往前取
2336
+ var matched = []
2337
+ for (var i = 0; i < ccvPins.length; i++) {
1892
2338
  var p = ccvPins[i]
1893
2339
  if (used && used.length && used.indexOf(p) >= 0) continue
1894
2340
  // 精确相等最好;但解析出来的原文在"原文本身含「」"时会被截短,
1895
2341
  // 所以也接受"它是上报原文的前缀"(截短只会往前截,不会变形)。
1896
2342
  if (p.text !== q && p.text.indexOf(q) !== 0) continue
1897
2343
  if (want && p.path && p.path !== want) continue
1898
- return p
2344
+ matched.push(p)
1899
2345
  }
1900
- return null
2346
+ if (!matched.length) return null
2347
+ var back = (typeof remaining === 'number' && remaining > 0) ? remaining : 1
2348
+ var k = matched.length - back
2349
+ if (k < 0) k = 0
2350
+ return matched[k]
1901
2351
  }
1902
2352
 
1903
2353
  // ── 二、从消息正文里认出「话布选区引用」──────────────────────────
@@ -2068,7 +2518,7 @@ function ccvSpotExcerpt(content, idx, quote) {
2068
2518
 
2069
2519
  // 单条引用 → 一段话。返回 '' 表示这条不值得注入(比如找不到文档)。
2070
2520
  // `used` 见 ccvPinFind:同一次拼装里上报记录不许被两条引用重复认领。
2071
- function ccvDescribeRef(ref, used) {
2521
+ function ccvDescribeRef(ref, used, remaining) {
2072
2522
  var who = ref.path || ref.title || '(未标注)'
2073
2523
  var c = ccvFindDoc(ref)
2074
2524
  if (!c) {
@@ -2097,6 +2547,21 @@ function ccvDescribeRef(ref, used) {
2097
2547
  + '动手前请让用户重新划一次要引用的段落,**不要**去找相似的内容照着改。'
2098
2548
  }
2099
2549
 
2550
+ // 定位这一条引用对应的**上报**(拿它换序数,也拿它查"有没有开过编辑区")。
2551
+ // 🔴 2026-10-11 起**提到分支之前** —— 单处引用也要查编辑区(编辑区与处数无关)。
2552
+ var pin = ccvPinFind(ref.path, quote, used, remaining)
2553
+ if (pin && used && used.indexOf(pin) < 0) used.push(pin)
2554
+
2555
+ // 这一条引用开过「选区编辑区」吗?(实施步骤 1:建区 + 绑定 + 注入)
2556
+ var area = pin ? scratchForPin(pin.code) : null
2557
+ var areaAt = -1
2558
+ if (area) {
2559
+ var ordA = (pin.ordinal > 0) ? pin.ordinal : 0
2560
+ areaAt = (area.scratch && area.scratch.offset >= 0) ? area.scratch.offset
2561
+ : ((ordA > 0 && ordA <= idxs.length) ? idxs[ordA - 1] : (idxs.length === 1 ? idxs[0] : -1))
2562
+ }
2563
+ var tail = area ? ('\n' + ccvScratchBlock(area, c, areaAt, quote.length)) : ''
2564
+
2100
2565
  var shown = idxs.slice(0, CCV_SEL_MAX_SPOTS)
2101
2566
 
2102
2567
  // 只有一处:直接说死
@@ -2104,7 +2569,7 @@ function ccvDescribeRef(ref, used) {
2104
2569
  var lc1 = lineColOf(content, idxs[0])
2105
2570
  return '· 《' + c.title + '》「' + ccvShortQuote(quote, CCV_SEL_QUOTE_MAX)
2106
2571
  + '」在第 ' + lc1.line + ' 行,**全文只有这一处**:\n '
2107
- + ccvSpotExcerpt(content, idxs[0], quote)
2572
+ + ccvSpotExcerpt(content, idxs[0], quote) + tail
2108
2573
  }
2109
2574
 
2110
2575
  // 多处:把目标那一处标出来,其余只报行号(够模型核对,又不啰嗦)
@@ -2112,8 +2577,6 @@ function ccvDescribeRef(ref, used) {
2112
2577
  // 序数从哪来:**客户端刚报上来的**优先(引用正文里已经不写了);
2113
2578
  // 拿不到才退回看抬头标签(兼容没更新的客户端 / 用户手打的引用)。
2114
2579
  // 两个都没有 → 绝不替模型挑一处,让它把每处摆给用户确认。
2115
- var pin = ccvPinFind(ref.path, quote, used)
2116
- if (pin && used && used.indexOf(pin) < 0) used.push(pin)
2117
2580
  var ordinal = (pin && pin.ordinal > 0) ? pin.ordinal : ref.ordinal
2118
2581
 
2119
2582
  var head = '· 《' + c.title + '》「' + ccvShortQuote(quote, CCV_SEL_QUOTE_MAX)
@@ -2143,6 +2606,36 @@ function ccvDescribeRef(ref, used) {
2143
2606
  } else {
2144
2607
  lines.push(' → 分不出用户要哪一处。请把上面每处的上下文摆给用户,让他确认改哪一处,不要自己挑一处改。')
2145
2608
  }
2609
+ return lines.join('\n') + tail
2610
+ }
2611
+
2612
+ // ─── 选区编辑区的注入(实施步骤 1:建区 + 绑定 + 注入,先不拼接)──────
2613
+ // 规格:canvas-docs/话布选区编辑区方案.md 第七节
2614
+ //
2615
+ // 为什么必须补语境:AI 只看到那一小段,你说"改成和上面一致",它没有"上面"。
2616
+ // 为什么最后那条要求是**硬要求**:AI 的习惯是写"以下是修改后的版本"、加代码块围栏 ——
2617
+ // 将来拼回原文时那些说明文字就变成文档内容了 ✗(本步不拼接,但约束现在就立好)。
2618
+ var CCV_SCRATCH_CTX = 60 // 上文/下文各取多少字符
2619
+
2620
+ function ccvScratchBlock(area, doc, offset, quoteLen) {
2621
+ if (!area) return ''
2622
+ var lines = []
2623
+ lines.push('· 上面这一段已经开成了**选区编辑区**(它不是给你看的,是给你改的工作台)。')
2624
+ lines.push(' 改它就用现成的 canvas_write 写这个画布:id = ' + area.id
2625
+ + '(现在 v' + area.version + ')。**不要去改主文档**。')
2626
+ lines.push(' 它现在的内容(原样):')
2627
+ String(area.content || '').split('\n').forEach(function (l) { lines.push(' │ ' + l) })
2628
+ if (doc && offset >= 0) {
2629
+ var content = String(doc.content || '')
2630
+ var before = content.slice(Math.max(0, offset - CCV_SCRATCH_CTX), offset)
2631
+ var after = content.slice(offset + (quoteLen || 0), offset + (quoteLen || 0) + CCV_SCRATCH_CTX)
2632
+ if (before.replace(/\s/g, '')) lines.push(' 上文:…' + before.replace(/\s+/g, ' '))
2633
+ if (after.replace(/\s/g, '')) lines.push(' 下文:' + after.replace(/\s+/g, ' ') + '…')
2634
+ } else {
2635
+ lines.push(' (它在原文里的位置没解析出来 —— **别自己猜**,需要定位就问用户)')
2636
+ }
2637
+ lines.push(' ⚠️ 要求:**只写替换后的内容本身**。不要写"以下是修改后的版本"这类说明,')
2638
+ lines.push(' 不要加代码块围栏,不要加前后缀 —— 你写的就是要放回原文那一段的东西。')
2146
2639
  return lines.join('\n')
2147
2640
  }
2148
2641
 
@@ -2156,6 +2649,10 @@ function ccvDescribeRef(ref, used) {
2156
2649
  // 只按正文做键就会把第二次挡掉,让模型拿着第一次的序数去改。
2157
2650
  var ccvSelCtxMemo = { key: null, val: '' }
2158
2651
 
2652
+ // 这一轮认到的引用(注入时留下,拼接时取用)。见 ccvBuildSelContext 里的注释:
2653
+ // 轮末再解析已经来不及 —— 队列那条消息被 claim 走了,界面那条不带引用 ✗。
2654
+ var ccvLastTurnRefs = null
2655
+
2159
2656
  function ccvBuildSelContext(context) {
2160
2657
  try {
2161
2658
  var agent = context && context.agent
@@ -2168,10 +2665,40 @@ function ccvBuildSelContext(context) {
2168
2665
  if (ccvSelCtxMemo.key === memoKey) return ccvSelCtxMemo.val
2169
2666
 
2170
2667
  var refs = ccvParseRefs(text)
2668
+ // 每条引用的 remaining = **从它起**(含自己)还有几条"同一篇 + 同一段原文"的引用。
2669
+ // 用途见 ccvPinFind:一条消息里两条文字完全相同的引用,靠它各归各位。
2670
+ var remainingOf = []
2671
+ for (var a = 0; a < refs.length; a++) {
2672
+ var n = 0
2673
+ for (var b = a; b < refs.length; b++) {
2674
+ if (String(refs[a].quote) === String(refs[b].quote)
2675
+ && (refs[a].path || refs[a].title) === (refs[b].path || refs[b].title)) n++
2676
+ }
2677
+ remainingOf.push(n)
2678
+ }
2679
+ // 🔴 把这一轮认到的引用**记下来**(2026-10-11 加,拼接要用)。
2680
+ // 为什么不能等轮末再解析:轮次一开始,队列那条消息就被 claim 走了
2681
+ // ('agent/inbox/claimed' —— "One message left the inbox inside its open turn"),
2682
+ // 轮末只能退回读「界面」那条 —— 而**它不带引用** ✗。
2683
+ //
2684
+ // ⚠️⚠️ 覆盖规则是踩出来的(2026-10-11 实测两轮):
2685
+ // 同一轮里本函数会被调多次,而**不同通道读出来的结果不一样** ——
2686
+ // 17:50:52 来自「队列」→ 1 条引用 ✓
2687
+ // 17:51:11 来自「界面」→ 0 条引用 ✗
2688
+ // 17:51:25 来自「界面」(**时间自己往前走了**:17:50:52 → 17:51:11)→ 0 条 ✗
2689
+ // 第一版我用"时间相同 = 同一条消息"来判断 —— 失效 ✗(界面那条的时间会变)。
2690
+ // 最终规则(不依赖时间,最简单也最稳):
2691
+ // **只在看到引用时才写缓存** ✓ —— 永远不用"空的"去覆盖"有引用的" ✗
2692
+ // 清空交给拼接那边:**每轮结束都清**(包括没有引用的轮次)。
2693
+ try {
2694
+ if (refs.length) {
2695
+ ccvLastTurnRefs = { refs: refs, remaining: remainingOf, text: text, src: picked.src, time: picked.time, at: Date.now() }
2696
+ }
2697
+ } catch (_) {}
2171
2698
  var blocks = []
2172
2699
  var usedPins = []
2173
2700
  for (var i = 0; i < refs.length && i < 3; i++) {
2174
- var one = ccvDescribeRef(refs[i], usedPins)
2701
+ var one = ccvDescribeRef(refs[i], usedPins, remainingOf[i])
2175
2702
  if (one) blocks.push(one)
2176
2703
  }
2177
2704
  var out = ''
@@ -2209,6 +2736,275 @@ try {
2209
2736
  }
2210
2737
  // [[CCV-SELCTX-END]]
2211
2738
 
2739
+ // ==================== 14-scratch-splice.js ====================
2740
+ // ═══ 选区编辑区的拼接(实施步骤 3)══════════════════════════════════
2741
+ // 规格:canvas-docs/话布选区编辑区方案.md 第六节 + 实施步骤 3
2742
+ // · 纯字符串手术:newDoc = doc[0..偏移] + 编辑区内容 + doc[偏移+长度..]
2743
+ // · 落成【一个】撤销步骤(走 applyWrite —— 它自己 push 一条历史)
2744
+ // · 本步**先不销毁**编辑区(销毁是第 4 步)
2745
+ // · **定位不了就报给主人,不猜、不硬拼**(设计稿第九节:不静默兜底)
2746
+ //
2747
+ // ═══ 钩子为什么是 agent/turn-stopping ═══════════════════════════════
2748
+ // 设计稿写的是 "turn/end",但 DSH 里**没有**叫 turn/end 的事件(查过事件目录)。
2749
+ // 对应的是 agent/turn-stopping —— 原话 "The turn is about to close: the model owes
2750
+ // no response",而且是 **serial 模式**:会被 await ✓ 正好保证"拼完才关这一轮"。
2751
+ //
2752
+ // ═══ 目标 = 本轮【引用到的】编辑区(设计稿第八节)═══════════════════
2753
+ // 1 条引用 → 改 1 个;N 条 → N 个;0 条 → 不拼(普通聊天)。
2754
+ // 靠的是与「位置主动注入」**同一套配对**(ccvParseRefs + ccvPinFind + scratchForPin)——
2755
+ // 两边必须一致,否则"注入说的"和"实际拼的"不是同一个地方 ✗(那正是最坏的情况)。
2756
+ //
2757
+ // ⚠️ 一个前提:编辑区必须**在同一个宿主进程里**活着。它是内存对象(不落盘 ✗)——
2758
+ // 重启就没了 ✓ 这是设计要的(一次性)。重启后引用还在、编辑区没了 → 这里查不到
2759
+ // 就**什么都不做**(不报错、不猜位置)。
2760
+
2761
+ // 报给主人看的痕迹(走 ccvTrace,客户端日志里能看到)
2762
+ // [[CCV-SPLICE-BEGIN]]
2763
+ function ccvSpliceTrace(m) { try { ccvTrace('[拼接] ' + m) } catch (_) {} }
2764
+
2765
+ // 找这一条引用对应的编辑区(配对口径与注入完全一致)
2766
+ function ccvScratchForRef(ref, used, remaining) {
2767
+ // ⚠️ 这里传的是 ref.quote(可能被「」截短),而 ccvPinFind 接受"上报原文的前缀" ✓
2768
+ // —— 所以不需要再做一次变体解析(注入那边做变体是为了在文档里定位 ✗ 这里不用)。
2769
+ var pin = ccvPinFind(ref.path, ref.quote, used, remaining)
2770
+ if (!pin) return null
2771
+ if (used && used.indexOf(pin) < 0) used.push(pin)
2772
+ var area = scratchForPin(pin.code)
2773
+ return area ? { area: area, pin: pin } : null
2774
+ }
2775
+
2776
+ // 拼一个编辑区。返回 { ok, why } —— why 是给人看的一句话
2777
+ function ccvSpliceArea(area) {
2778
+ try {
2779
+ var b = (area && area.scratch) || {}
2780
+ var doc = b.docId ? canvases.get(b.docId) : null
2781
+ if (!doc) return { ok: false, why: '主文档不在了(可能被删/改名)' }
2782
+ if (b.spliced) return { ok: false, why: '这个编辑区已经拼过了' }
2783
+
2784
+ var cur = String(doc.content || '')
2785
+ var orig = String(b.origQuote || '')
2786
+ var off = (typeof b.offset === 'number') ? b.offset : -1
2787
+ var len = (typeof b.length === 'number') ? b.length : orig.length
2788
+
2789
+ // ── 过期校验(设计稿第十一节:版本 → 指纹 → 序数重定位,失败就报)──
2790
+ var why = ''
2791
+ var at = -1
2792
+ if (off >= 0 && off + len <= cur.length && cur.slice(off, off + len) === orig) {
2793
+ at = off // ① 原位还在:直接用
2794
+ } else if (orig) {
2795
+ // ② 原位不在了 → 按「引文 + 序数」重定位(与建区时同一套算法)
2796
+ var hits = occIndexes(cur, orig)
2797
+ var ord = (typeof b.ordinal === 'number' && b.ordinal > 0) ? b.ordinal : 0
2798
+ if (ord > 0 && ord <= hits.length) { at = hits[ord - 1]; why = '原位已变,按序数重定位到第 ' + ord + ' 处' }
2799
+ else if (hits.length === 1) { at = hits[0]; why = '原位已变,全文只剩一处,用它' }
2800
+ else if (!hits.length) return { ok: false, why: '这段原文在主文档里已经找不到了(引用失效)' }
2801
+ else return { ok: false, why: '这段原文出现 ' + hits.length + ' 次、序数也对不上 —— 分不出该改哪一处,**没有拼**' }
2802
+ } else {
2803
+ return { ok: false, why: '没有原始引文可比对(老编辑区缺这个字段)' }
2804
+ }
2805
+
2806
+ var newDoc = cur.slice(0, at) + String(area.content || '') + cur.slice(at + len)
2807
+ if (newDoc === cur) return { ok: false, why: '拼完和原文一样(编辑区没改动)' }
2808
+
2809
+ // 落成**一个**撤销步骤:applyWrite 自己 push 一条历史 ✓
2810
+ var res = applyWrite(doc, newDoc, 'replace', doc.version, 'ai')
2811
+ if (!res || res.conflict) return { ok: false, why: '版本冲突,没拼' }
2812
+
2813
+ b.spliced = true
2814
+ b.splicedAt = Date.now()
2815
+ b.splicedVersion = res.version
2816
+ return { ok: true, why: why || '原位拼入', doc: doc, at: at, len: len, version: res.version }
2817
+ } catch (e) {
2818
+ return { ok: false, why: '拼接出错:' + ((e && e.message) || e) }
2819
+ }
2820
+ }
2821
+
2822
+ // 这一轮结束时把所有"本轮引用到的"编辑区拼回去
2823
+ //
2824
+ // ⚠️ 入口**必须留痕**(2026-10-11 补):处理函数"没事可做时静默返回"✗,
2825
+ // 于是"钩子没触发"和"触发了但没活干"在日志里**长得一模一样** ✗ ——
2826
+ // 主人报"根本没改"时我就是卡在这儿分不清是哪种。只记前几次,不刷屏。
2827
+ var ccvSpliceFires = 0
2828
+ async function ccvSpliceTurn(payload) {
2829
+ try {
2830
+ var fresh = ccvSpliceFires < 5
2831
+ if (fresh) {
2832
+ ccvSpliceFires++
2833
+ ccvSpliceTrace('钩子触发第 ' + ccvSpliceFires + ' 次(turn=' + ((payload && payload.turn !== undefined) ? payload.turn : '?') + ')')
2834
+ }
2835
+ var agent = payload && payload.agent
2836
+ // ⚠️ 没有 agent 也要能走:引用优先从**注入时留下的那份**取(见下),
2837
+ // 那条路不需要 agent。只有退回"现解析"时才必须有 agent。
2838
+ if (!agent && !(ccvLastTurnRefs && ccvLastTurnRefs.refs && ccvLastTurnRefs.refs.length)) {
2839
+ if (fresh) ccvSpliceTrace('既没有 agent、也没有注入时留下的引用,跳过')
2840
+ return
2841
+ }
2842
+
2843
+ // 🔴 引用从**注入时留下的那份**取,不在轮末重新解析(2026-10-11 实测的坑):
2844
+ // 轮次一开始,队列那条消息就被 claim 走了('agent/inbox/claimed' ——
2845
+ // "One message left the inbox inside its open turn"),轮末 ccvTurnMsg 只能
2846
+ // 退回读「界面」那条 —— 而**它不带引用** ✗。
2847
+ // 实测:注入看到 1 条引用 ✓ 拼接看到 0 条 ✗,就卡在这儿。
2848
+ var refs = []
2849
+ var remainingOf = []
2850
+ if (ccvLastTurnRefs && ccvLastTurnRefs.refs && ccvLastTurnRefs.refs.length) {
2851
+ refs = ccvLastTurnRefs.refs
2852
+ remainingOf = ccvLastTurnRefs.remaining || []
2853
+ if (fresh) ccvSpliceTrace('用注入时留下的引用 ' + refs.length + ' 条(来自「' + ccvLastTurnRefs.src + '」)')
2854
+ } else {
2855
+ var picked = ccvTurnMsg(agent)
2856
+ if (!picked) { if (fresh) ccvSpliceTrace('取不到本轮的人消息,跳过'); return }
2857
+ refs = ccvParseRefs(picked.text)
2858
+ if (fresh) ccvSpliceTrace('没有注入时留下的引用,退回现解析:' + refs.length + ' 条')
2859
+ // remaining 的口径必须与注入一致(两条一模一样的引用靠它各归各位)
2860
+ for (var a = 0; a < refs.length; a++) {
2861
+ var n = 0
2862
+ for (var b2 = a; b2 < refs.length; b2++) {
2863
+ if (String(refs[a].quote) === String(refs[b2].quote)
2864
+ && (refs[a].path || refs[a].title) === (refs[b2].path || refs[b2].title)) n++
2865
+ }
2866
+ remainingOf.push(n)
2867
+ }
2868
+ }
2869
+ // ★ 读出来就立刻清空 —— 无论后面怎么走(早退 / 失败 / 没配上),
2870
+ // 这一轮的引用都不该留给下一轮 ✗(否则下一轮没引用时会拿旧引用乱拼 ✗✗)
2871
+ try { ccvLastTurnRefs = null } catch (_) {}
2872
+ if (!refs.length) { if (fresh) ccvSpliceTrace('本轮没有引用,跳过'); return }
2873
+
2874
+ // 配到了几个编辑区 —— 留一行痕:万一下一环(配对)出问题,一眼能看到 ✗→✓
2875
+ if (fresh) {
2876
+ var probe = 0
2877
+ var probeUsed = []
2878
+ for (var pi = 0; pi < refs.length && pi < 3; pi++) {
2879
+ if (ccvScratchForRef(refs[pi], probeUsed, remainingOf[pi] || 1)) probe++
2880
+ }
2881
+ ccvSpliceTrace('引用 ' + refs.length + ' 条,其中配到编辑区 ' + probe + ' 个'
2882
+ + (probe ? '' : '(配不上 → 检查 pinCode 绑定)'))
2883
+ }
2884
+
2885
+ // ── 先把「引用 ↔ 编辑区」全配好(**配对口径不能乱** ✗)──────────────
2886
+ // 配对必须按引用在消息里的顺序做(第 N 条引用 ↔ 第 N 笔上报,见 ccvPinFind ✓),
2887
+ // 但**拼接的顺序不能跟着它** —— 见下面的排序。
2888
+ var used = []
2889
+ var jobs = []
2890
+ for (var i = 0; i < refs.length && i < 3; i++) {
2891
+ var hit = ccvScratchForRef(refs[i], used, remainingOf[i])
2892
+ if (!hit) continue // 这一条引用没开过编辑区 → 不拼(普通引用)
2893
+ var sb = hit.area.scratch || {}
2894
+ jobs.push({
2895
+ area: hit.area,
2896
+ off: (typeof sb.offset === 'number') ? sb.offset : -1,
2897
+ docId: sb.docId || '',
2898
+ })
2899
+ }
2900
+
2901
+ // ★ 设计稿第 5 步的「串行」(2026-10-11 补):**同一篇文档里多个编辑区,
2902
+ // 按偏移从大到小拼** ✓
2903
+ // 为什么必须这样:拼接是"换掉文档 [偏移, 偏移+长度) 那一段",
2904
+ // 先拼**前面**那个会让**后面**那个的偏移整体前移/后移 ✗ ——
2905
+ // 接着拼第二个时,"原位那段还在吗"的校验会失败 ✗,然后退回按序数重定位;
2906
+ // 而序数是"这段文字第几次出现"——第一个区已经被换掉了,计数跟着变 ✗
2907
+ // → 可能重定位到**错误的**那一处,静默改错地方 ✗✗
2908
+ // 从大到小拼就没有这个问题:动后面那一段,不影响前面任何偏移 ✓
2909
+ jobs.sort(function (a, b) { return b.off - a.off })
2910
+
2911
+ var tried = 0
2912
+ var done = 0
2913
+ var failed = []
2914
+ for (var j = 0; j < jobs.length; j++) {
2915
+ var area = jobs[j].area
2916
+ tried++
2917
+ var r = ccvSpliceArea(area)
2918
+ if (r.ok) {
2919
+ done++
2920
+ ccvSpliceTrace('拼入《' + (r.doc ? r.doc.title : '?') + '》第 ' + r.at + ' 字处('
2921
+ + r.len + ' 字 → ' + String(area.content || '').length + ' 字),'
2922
+ + r.why + ',现在 v' + r.version)
2923
+ // ★ 第 4 步:拼接成功 → **立刻销毁**编辑区(设计稿第四节:一次性对象,全自动)
2924
+ // 它是内存对象(不落盘 ✓),删掉即消失 ✓ 主人不需要做任何操作 ✓
2925
+ // ⚠️ 只在**成功**时销毁:失败时留着 —— 第九节是"报给主人",不是"偷偷丢掉证据" ✗
2926
+ try {
2927
+ canvases.delete(area.id)
2928
+ ccvSpliceTrace('编辑区 ' + area.id + ' 已销毁(一次性对象)')
2929
+ } catch (_) {}
2930
+ } else {
2931
+ failed.push(r.why)
2932
+ ccvSpliceTrace('没拼:' + r.why + '(编辑区 ' + area.id + ' 留着)')
2933
+ }
2934
+ }
2935
+ // 用掉就清空 —— 现在改成"读出来就清"(见上),这里不再重复。
2936
+ // 失败也清:设计稿第九节是"报给主人",不是"下轮偷偷重试" ✓
2937
+ if (!tried) return
2938
+ if (failed.length) {
2939
+ // ⚠️ 设计稿第九节:**不静默兜底** —— 定位不了就报给主人
2940
+ try {
2941
+ console.warn('[collab-canvas] 选区编辑区未拼入:' + failed.join(';'))
2942
+ } catch (_) {}
2943
+ }
2944
+ ccvSpliceTrace('本轮结束:引用 ' + refs.length + ' 条,带编辑区 ' + tried + ' 个,拼入 ' + done
2945
+ + ' 个' + (failed.length ? ',没拼 ' + failed.length + ' 个' : ''))
2946
+ } catch (e) {
2947
+ try { console.error('[collab-canvas] scratch splice failed:', e && e.message) } catch (_) {}
2948
+ }
2949
+ }
2950
+
2951
+ // 注册成宿主事件监听。
2952
+ //
2953
+ // 🔴 为什么注册在 **ctx.root** 而不是插件自己的 ctx(2026-10-11 实测定位):
2954
+ // 作用域是一棵树 ——
2955
+ // 根
2956
+ // ├── 插件 ctx(我们的插件挂在 profile 级,是**兄弟分支**)
2957
+ // └── 会话 / agent 作用域 ← agent/turn-stopping 从这里派发
2958
+ // 事件只往上冒到**祖先** ✓,兄弟分支收不到 ✗ —— 所以挂在自己的 ctx 上**一次都不会触发**
2959
+ // (实测:注册留痕在 ✓ 但"钩子触发"一行都没有 ✗)。
2960
+ // 官方那些插件能收到,是因为它们挂在**会话作用域**里(agent preset 内);我们不是,
2961
+ // 所以必须显式挂到根上。
2962
+ //
2963
+ // 注册处**必须留痕**:主人报"根本没改"时,我分不清
2964
+ // ① 跑着的宿主里有没有这个模块(重启了没)
2965
+ // ② 有模块、但事件没触发
2966
+ // 两者表现**一模一样** ✗。留一行痕就一眼分得清。
2967
+ //
2968
+ // 两个钩子都挂(重复触发是安全的:编辑区上有 `spliced` 标记,拼过的不会再拼 ✓):
2969
+ // · agent/turn-stopping —— 语义最准("这一轮就要关了"),serial 模式会被 await ✓
2970
+ // · agent/status → idle —— 兜底
2971
+ try {
2972
+ var evCtx = (ctx && ctx.root) ? ctx.root : ctx
2973
+ var onRoot = !!(ctx && ctx.root && evCtx === ctx.root)
2974
+ if (evCtx && typeof evCtx.on === 'function') {
2975
+ // ★ 主力:官方的**持久事件流**(post-commit),turn/end 是轮末的权威信号。
2976
+ // 抄的是官方插件 dsh-workspace-changes 的写法(它干的正是"一轮结束后收尾")。
2977
+ // 为什么它比 agent/turn-stopping 可靠:
2978
+ // · 它是**持久**事件 —— 不依赖收件箱那条消息还在不在(那条会被 claim 走 ✗)
2979
+ // · 它带 turn 号 —— 出问题时能对着会话日志查
2980
+ evCtx.on('session/event', function (session, event) {
2981
+ try {
2982
+ if (!event || event.type !== 'turn/end') return
2983
+ var turn = (event.data && event.data.turn !== undefined) ? event.data.turn : '?'
2984
+ if (ccvSpliceFires < 5) ccvSpliceTrace('session/event: turn/end(turn=' + turn + ')→ 触发拼接')
2985
+ return ccvSpliceTurn({ session: session, turn: turn, fromSessionEvent: true })
2986
+ } catch (_) {}
2987
+ })
2988
+ // 次选:agent 侧那两个(语义也准,但实测依赖收件箱/作用域,容易哑火)
2989
+ evCtx.on('agent/turn-stopping', ccvSpliceTurn)
2990
+ evCtx.on('agent/status', function (payload) {
2991
+ try {
2992
+ if (payload && payload.status === 'idle') return ccvSpliceTurn(payload)
2993
+ } catch (_) {}
2994
+ })
2995
+ ccvSpliceTrace('已注册(挂到 ' + (onRoot ? 'ctx.root ✓' : '插件自己的 ctx ✗ 收不到 agent 事件')
2996
+ + ';主力 session/event:turn/end + 次选 agent/turn-stopping、agent/status:idle)—— 模块已加载')
2997
+ console.log('[collab-canvas]', 'scratch splice registered on ' + (onRoot ? 'ctx.root' : 'own ctx'))
2998
+ } else {
2999
+ ccvSpliceTrace('没注册上:ctx.on 不可用')
3000
+ console.error('[collab-canvas] ctx.on 不可用,编辑区拼接没注册上')
3001
+ }
3002
+ } catch (e) {
3003
+ ccvSpliceTrace('注册失败:' + ((e && e.message) || e))
3004
+ console.error('[collab-canvas] scratch splice register failed:', e && e.message)
3005
+ }
3006
+ // [[CCV-SPLICE-END]]
3007
+
2212
3008
  // ==================== 99-tail.js ====================
2213
3009
  // 启动引导:恢复持久化状态 + 注册 HTTP 端点
2214
3010
  ;(async function () {