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/README.md +16 -5
- package/dist/host.js +957 -161
- package/lib/ccv-logic.js +281 -0
- package/lib/client.js +1058 -1633
- package/lib/doc-model.js +291 -0
- package/lib/selref.js +107 -20
- package/lib/viewer-md.js +4075 -0
- package/package.json +4 -3
- package/lib/docdrop.js +0 -362
- package/lib/doclink.js +0 -436
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()).
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
508
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
//
|
|
978
|
-
|
|
979
|
-
|
|
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
|
-
|
|
1010
|
-
|
|
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
|
-
|
|
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) {
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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)) {
|
|
1363
|
+
if (isNaN(since)) { httpError(res, 400, { ok: false, error: 'since 必须是数字' }); return }
|
|
1040
1364
|
const out = aiChanges.filter((x) => x.seq > since)
|
|
1041
|
-
|
|
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') {
|
|
1053
|
-
const body = await
|
|
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
|
-
|
|
1056
|
-
} catch (e) {
|
|
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
|
|
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) {
|
|
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
|
-
// 若照原样
|
|
1446
|
+
// 若照原样 httpJson({ ok: true, version: result.version }),
|
|
1069
1447
|
// undefined 会被 JSON.stringify 丢掉 → 响应变成 {"ok":true} ——
|
|
1070
1448
|
// 冲突被伪装成成功,浏览器编辑器无从察觉,只能继续用旧内容写。
|
|
1071
1449
|
if (result && result.conflict) {
|
|
1072
|
-
|
|
1450
|
+
httpJson(res, { ok: false, conflict: true, currentVersion: result.currentVersion, currentContent: result.currentContent })
|
|
1073
1451
|
return
|
|
1074
1452
|
}
|
|
1075
|
-
|
|
1076
|
-
} catch (e) {
|
|
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
|
|
1491
|
+
const body = await httpReadJson(req)
|
|
1083
1492
|
const c = createCanvasDoc(body.title || '新话布')
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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) {
|
|
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
|
-
|
|
1778
|
+
httpJson(res, { ok: true, folders: regFolders.slice(), files: regTotal, note: '索引构建中' })
|
|
1348
1779
|
return
|
|
1349
1780
|
}
|
|
1350
|
-
|
|
1351
|
-
} catch (e) {
|
|
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
|
-
|
|
1376
|
-
} catch (e) {
|
|
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)) {
|
|
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)) {
|
|
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.
|
|
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
|
-
|
|
1410
|
-
|
|
1411
|
-
} catch (e) {
|
|
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) {
|
|
1438
|
-
if (!/\.(md|markdown|txt|text)$/i.test(raw)) {
|
|
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
|
-
|
|
1872
|
+
httpError(res, 403, { ok: false, error: '路径不在允许范围内(会话工作区 / 画布目录 / 已登记文件夹)' })
|
|
1442
1873
|
return
|
|
1443
1874
|
}
|
|
1444
|
-
if (!nodefs.existsSync(abs)) {
|
|
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
|
-
|
|
1452
|
-
|
|
1453
|
-
} catch (e) {
|
|
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) {
|
|
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.
|
|
1912
|
+
} catch (e) { console.httpError('[collab-canvas] delete file failed:', e && e.message) }
|
|
1482
1913
|
persistMeta()
|
|
1483
1914
|
}
|
|
1484
|
-
|
|
1485
|
-
} catch (e) {
|
|
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') {
|
|
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
|
|
1496
|
-
if (!body || !body.length) {
|
|
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
|
-
|
|
1503
|
-
} catch (e) {
|
|
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)) {
|
|
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) {
|
|
1516
|
-
if (!nodefs.existsSync(full)) {
|
|
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.
|
|
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
|
-
|
|
1535
|
-
|
|
1536
|
-
} catch (e) {
|
|
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
|
-
|
|
1994
|
+
httpError(res, 403, { ok: false, error: '非法路径' }); return
|
|
1564
1995
|
}
|
|
1565
1996
|
if (!nodefs.existsSync(full) || !nodefs.statSync(full).isFile()) {
|
|
1566
|
-
|
|
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) {
|
|
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') {
|
|
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) {
|
|
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
|
-
|
|
1597
|
-
} catch (e) {
|
|
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/
|
|
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
|
-
|
|
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) {
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 () {
|