collab-canvas 0.3.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 ADDED
@@ -0,0 +1,2231 @@
1
+ // DSH Collab-Canvas · host bundle — GENERATED — DO NOT EDIT
2
+ // 改源码 src/host/*.js 后重新构建:node tools/build.cjs
3
+ import { defineTool } from "@deepseek-ai/dsh-tools"
4
+ import nodefs from "node:fs"
5
+ import path from "node:path"
6
+ import { exec, execFile, spawn } from "node:child_process"
7
+ export const name = 'collab-canvas-host'
8
+ export const inject = ["timer","tools","fs","webServer","systemPrompt"]
9
+ export function apply(ctx, config) {
10
+
11
+ // ==================== 00-ctx.js ====================
12
+ // 上下文服务引用(模块共享)——composition 插件用 injected 服务
13
+ const fs = ctx.fs
14
+ const policy = ctx.get('sandboxPolicy') // 可选服务,未注入时可能 undefined
15
+
16
+ // ==================== 01-utils.js ====================
17
+ // 通用工具函数
18
+ function joinPath(a, b) {
19
+ return String(a || '.').replace(/[\\/]+$/, '') + '/' + b
20
+ }
21
+ function baseName(p) {
22
+ const s = String(p)
23
+ const i = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'))
24
+ return i >= 0 ? s.slice(i + 1) : s
25
+ }
26
+ function slugify(title) {
27
+ const s = String(title || '').replace(/[\\/:*?"<>|#%]+/g, '').trim().replace(/\s+/g, '-')
28
+ return s || 'untitled'
29
+ }
30
+ function err(code, message) { return { ok: false, error: { code: code, message: message } } }
31
+
32
+ // ==================== 03-state.js ====================
33
+ // 画布注册表状态 + 存储根多级探测
34
+ var canvases = new Map() // id -> {id,title,type,content,version,filePath,history[],redoStack[],dirty,updatedAt}
35
+ var activeId = null
36
+ var idSeq = 0
37
+ var rootOverride = null // 用户显式固定的存储根(持久化于 meta)
38
+
39
+ // 插件自己的用户级目录。**关键性质:与 DSH 无关。**
40
+ // 为什么必须有它:DSH 升级可能把 sessions / workspaceRegistry / policy.workspaceRoot
41
+ // **全部**改指向新的默认工作区 —— 2026-09-28 实测升级后三个源都变成
42
+ // ~/.zcode/workspace/default,于是 meta 的两个候选位置里都不再包含真正的存储根,
43
+ // restore 读不到 rootOverride,插件当成"首次运行"在错误目录里新建了一份空的。
44
+ // 只有这里不受影响,所以它同时是①存储根彻底探测不到时的兜底、
45
+ // ②meta 的第三个落点(见 04-persistence 的 metaTargets)。
46
+ function userHome() {
47
+ return (typeof process !== 'undefined' && process.env
48
+ && (process.env.USERPROFILE || process.env.HOME)) || ''
49
+ }
50
+ function fallbackBase() {
51
+ const home = userHome()
52
+ return home ? joinPath(home, '.dsh-collab-canvas') : '.'
53
+ }
54
+ function fallbackDocsDir() { return joinPath(fallbackBase(), 'canvas-docs') }
55
+
56
+ function docsDirBase() {
57
+ // 多级探测:显式覆盖 > 活跃会话 cwd > workspace 注册表 > 部署根兜底
58
+ if (rootOverride) return rootOverride
59
+ try {
60
+ const svc = ctx.get('sessions')
61
+ const arr = svc && svc.list ? svc.list() : []
62
+ for (const s of arr) {
63
+ const holder = s && (s.header || s.meta || s)
64
+ const cwd = holder && holder.cwd
65
+ if (typeof cwd === 'string' && cwd.length > 2) return cwd
66
+ }
67
+ } catch (e) { console.error('[collab-canvas] session-cwd probe failed:', e && e.message) }
68
+ try {
69
+ const reg = ctx.get('workspaceRegistry')
70
+ const list = reg && reg.list ? reg.list() : []
71
+ if (list[0] && typeof list[0].path === 'string' && list[0].path.length > 2) return list[0].path
72
+ } catch (e) { console.error('[collab-canvas] workspace-registry probe failed:', e && e.message) }
73
+ if (policy !== undefined && policy.workspaceRoot) return policy.workspaceRoot
74
+ // ⚠️ 兜底绝不能用 '.'(进程 cwd):DSH 的 cwd 可能就在它自己的安装目录里。
75
+ // 实测 2026-09-12 20:28 落到 node_modules/@deepseek-ai/dsh/lib —— 插件把
76
+ // file-registry.json 和 canvas-docs/.canvases.json 写进了 DSH 的包目录。
77
+ // 改用一个稳定的、与 DSH 无关的用户级目录。
78
+ const fallback = fallbackBase()
79
+ console.error('[collab-canvas] 存储根探测全部失败(sessions / workspaceRegistry / policy 都不可用),'
80
+ + '退到兜底目录:', fallback, '——请检查会话工作区是否正常')
81
+ return fallback
82
+ }
83
+ function docsDir() { return joinPath(docsDirBase(), 'canvas-docs') }
84
+ function metaPath() { return joinPath(docsDir(), '.canvases.json') }
85
+
86
+ // 画布对应的 md 文件「真实路径」(绝对、正斜杠)。
87
+ // filePath 为空时按存储根 + slugify(标题) 推算(与 saveCanvas 的落盘规则一致)。
88
+ //
89
+ // ⚠️ 别再让调用方自己拼 "canvas-docs/<标题>.md":那是**相对**路径,只有当
90
+ // 「会话工作区 == 存储根」时才成立。存储根被 canvas_configure 固定到别处后
91
+ // (rootOverride),它指向的是工作区里的残留目录 —— 文件根本不在那儿。
92
+ // 2026-09-14 另一会话据此报过一次「引用给出的路径不存在」。
93
+ function canvasMdPath(c) {
94
+ if (!c) return ''
95
+ let p = c.filePath
96
+ if (!p) { try { p = joinPath(docsDir(), slugify(c.title) + '.md') } catch (_) { p = '' } }
97
+ return String(p).replace(/\\/g, '/')
98
+ }
99
+
100
+ // ==================== 04-persistence.js ====================
101
+ // 持久化:多策略沙箱写入、保存(含认可区回退)、meta 双写、防抖冲刷、启动恢复
102
+ function candidatePolicies() {
103
+ const list = []
104
+ list.push({ tag: 'literal-danger', ep: { mode: 'danger-full-access' } })
105
+ try {
106
+ const r = policy && typeof policy.resolve === 'function' ? policy.resolve({ mode: 'danger-full-access' }) : null
107
+ if (r) list.push({ tag: 'resolve-danger', ep: r })
108
+ } catch (e) { console.error('[collab-canvas] resolve({mode}) rejected:', e && e.message) }
109
+ try {
110
+ const r2 = policy && typeof policy.resolve === 'function' ? policy.resolve({}) : null
111
+ if (r2) list.push({ tag: 'resolve-default', ep: r2 })
112
+ } catch (_) { /* ignore */ }
113
+ list.push({ tag: 'bare', ep: null })
114
+ return list
115
+ }
116
+
117
+ async function writeFile(path, content) {
118
+ if (!fs) throw new Error('fs 服务不可用')
119
+ const target = await fs.resolve(path)
120
+ let lastErr = null
121
+ for (const cand of candidatePolicies()) {
122
+ try {
123
+ if (cand.ep) await fs.writeText(target, content, undefined, undefined, cand.ep)
124
+ else await fs.writeText(target, content)
125
+ console.log('[collab-canvas]', 'write ok via', cand.tag, '->', path)
126
+ return true
127
+ } catch (e) {
128
+ lastErr = e
129
+ console.error('[collab-canvas]', 'write fail via', cand.tag, ':', e && e.message)
130
+ }
131
+ }
132
+ throw lastErr || new Error('写入失败')
133
+ }
134
+
135
+ function sanctionedDocsDir() {
136
+ return joinPath((policy !== undefined && policy.workspaceRoot) ? policy.workspaceRoot : '.', 'canvas-docs')
137
+ }
138
+
139
+ // meta 的落点清单。**persistMeta 与 restore 必须用同一份** ——
140
+ // 两边各写一份清单,一旦漂移就会出现「写进去了,但冷启动读不到」这种最难查的故障。
141
+ //
142
+ // 第三个落点(用户级、与 DSH 无关)是 2026-09-28 补的:
143
+ // DSH 升级后 sessions / workspaceRegistry / policy.workspaceRoot **全部**改指向
144
+ // ~/.zcode/workspace/default,前两个落点里都不再包含真正的存储根,
145
+ // 于是 rootOverride 读不回来、插件在错误目录里当成首次运行。
146
+ // 加了它之后,DSH 再怎么换默认工作区,rootOverride 都还在。
147
+ function metaTargets() {
148
+ const list = [metaPath(), joinPath(sanctionedDocsDir(), '.canvases.json')]
149
+ try {
150
+ const f = fallbackDocsDir()
151
+ if (f && f !== '.') {
152
+ const p = joinPath(f, '.canvases.json')
153
+ if (list.indexOf(p) < 0) list.push(p)
154
+ }
155
+ } catch (_) { /* 拿不到 home 就只用前两个 */ }
156
+ return list
157
+ }
158
+
159
+ async function saveCanvas(c, filePath) {
160
+ const primary = filePath || c.filePath || joinPath(docsDir(), slugify(c.title) + '.md')
161
+ try {
162
+ await writeFile(primary, c.content)
163
+ c.filePath = primary
164
+ c.dirty = false
165
+ return { path: primary, relocated: false }
166
+ } catch (e) {
167
+ // 显式指定路径失败不静默改道;默认目录被拒则回退到沙箱认可区
168
+ if (filePath || c.filePath) throw e
169
+ const p2 = joinPath(sanctionedDocsDir(), slugify(c.title) + '.md')
170
+ await writeFile(p2, c.content)
171
+ c.filePath = p2
172
+ c.dirty = false
173
+ return { path: p2, relocated: true }
174
+ }
175
+ }
176
+
177
+ 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 }
180
+ }) }, null, 2)
181
+ // 同步写所有候选位置:冷启动时无论读到哪一份都带 rootOverride
182
+ const targets = metaTargets()
183
+ let okCount = 0
184
+ for (const mp of targets) {
185
+ try { await writeFile(mp, payload); okCount++ } catch (e) { console.error('[collab-canvas] meta persist miss:', mp, e && e.message) }
186
+ }
187
+ if (!okCount) console.error('[collab-canvas] meta persist failed at all locations')
188
+ }
189
+
190
+ // 所有落盘**串成一条链**。为什么必须串:
191
+ // `saveCanvas` 是 async,写的是 c.content。两次写并发时,如果「第二次先完成、
192
+ // 第一次后完成」,文件最后会停在**旧内容**上 —— 而且两边都报成功,没人会发现。
193
+ // 串起来之后一次只写一个,顺序就是调用顺序。
194
+ // 返回的还是**原始** promise(调用方仍能 catch 到失败),只是把链接续上了 ——
195
+ // 链本身不因某一次失败而断掉,后面的写该发还得发。
196
+ var saveChain = Promise.resolve()
197
+ function saveSerial(c) {
198
+ var p = saveChain.then(function () { return saveCanvas(c) })
199
+ saveChain = p.then(function () {}, function () {})
200
+ return p
201
+ }
202
+
203
+ async function flushDirty() {
204
+ const dirty = []
205
+ canvases.forEach(function (c) { if (c.dirty) dirty.push(c) })
206
+ let failed = 0
207
+ for (const c of dirty) {
208
+ try { await saveSerial(c) } catch (e) { failed++; console.error('[collab-canvas] autosave failed:', c.title, e.message) }
209
+ }
210
+ if (dirty.length > failed) persistMeta()
211
+ }
212
+ const debouncedFlush = ctx.debounce(function () { flushDirty() }, 1500)
213
+ ctx.effect(function () { return function () { flushDirty() } }, 'final-flush')
214
+
215
+ async function restore() {
216
+ if (!fs) { console.error('[collab-canvas]', 'fs service unavailable — persistence disabled'); return }
217
+ const metaCandidates = metaTargets()
218
+ const metas = []
219
+ for (const mp of metaCandidates) {
220
+ try {
221
+ const raw = await fs.readText(await fs.resolve(mp))
222
+ metas.push(JSON.parse(raw))
223
+ console.log('[collab-canvas]', 'meta found at', mp)
224
+ } catch (e) { /* try next */ }
225
+ }
226
+ // 多份 meta 并存时,优先采信带 rootOverride 的那份
227
+ const meta = metas.find(function (m) { return m && typeof m.rootOverride === 'string' && m.rootOverride.length > 2 }) || metas[0] || null
228
+ if (!meta) { console.log('[collab-canvas]', 'no previous meta (first run)'); return }
229
+ try {
230
+ if (typeof meta.rootOverride === 'string' && meta.rootOverride.length > 2) rootOverride = meta.rootOverride
231
+ // 兜底去重:历史 meta 里可能已经存了「同一文件挂多条记录」。
232
+ // 两条记录各自持有内存内容,过期的旧记录一旦保存就会把文件覆盖回去,
233
+ // 所以恢复阶段就要把重复挡掉(按归一化后的 filePath 判重,先到先得)。
234
+ const seenFiles = new Set()
235
+ let skippedDup = 0
236
+ for (const m of meta.canvases || []) {
237
+ if (m.filePath) {
238
+ let key
239
+ try { key = path.resolve(String(m.filePath)) } catch (_) { key = String(m.filePath) }
240
+ if (seenFiles.has(key)) {
241
+ skippedDup++
242
+ console.error('[collab-canvas] restore: 跳过重复文件记录', m.id, '->', m.filePath)
243
+ continue
244
+ }
245
+ seenFiles.add(key)
246
+ }
247
+ let content = ''
248
+ if (m.filePath) {
249
+ try { content = await fs.readText(await fs.resolve(m.filePath)) }
250
+ catch (e) { console.error('[collab-canvas] restore miss:', m.filePath, e.message); continue }
251
+ }
252
+ canvases.set(m.id, {
253
+ id: m.id, title: m.title, type: m.type || 'document', content: content,
254
+ version: 1, filePath: m.filePath || null, history: [], redoStack: [],
255
+ dirty: false, updatedAt: Date.now(),
256
+ })
257
+ }
258
+ if (skippedDup) console.error('[collab-canvas] restore: 共跳过', skippedDup, '条重复记录')
259
+ activeId = meta.activeId && canvases.has(meta.activeId) ? meta.activeId : (meta.canvases && meta.canvases[0] && meta.canvases[0].id) || null
260
+ console.log('[collab-canvas]', 'restored', canvases.size, 'canvas(es), root =', docsDirBase())
261
+ // 自愈:本次若是从别的落点读到 rootOverride,立刻把 meta 写回**全部**落点。
262
+ // 否则那几份仍停在旧根上,下次探测顺序一变又会被带偏。
263
+ // 失败不致命(本次内存里的根已经是对的),所以只记日志。
264
+ if (typeof meta.rootOverride === 'string' && meta.rootOverride.length > 2) {
265
+ try { await persistMeta() } catch (e) { console.error('[collab-canvas] restore 后回写 meta 失败:', e && e.message) }
266
+ }
267
+ } catch (e) {
268
+ console.log('[collab-canvas]', 'restore failed:', e.message)
269
+ }
270
+ }
271
+
272
+ // ==================== 05-mutations.js ====================
273
+ // 共享变更逻辑(RPC 与模型工具共用同一套历史栈)
274
+ var HISTORY_MAX = 50
275
+ function pushHistory(c, before, after, source, version) {
276
+ c.history.push({ opId: 'op-' + Date.now().toString(36) + '-' + (++idSeq), source: source, before: before, after: after, at: Date.now(), version: version })
277
+ if (c.history.length > HISTORY_MAX) c.history.shift()
278
+ c.redoStack.length = 0
279
+ }
280
+
281
+ function createCanvasDoc(title) {
282
+ // 标题净化:AI/导入可能把 "xxx.md" 当标题传入,canvas_save 落盘时会变成
283
+ // "xxx.md.md" 双扩展名文件——这里统一剥掉尾扩展名
284
+ title = String(title || '').replace(/\.(md|markdown)$/i, '') || '未命名话布'
285
+ const id = 'cv-' + Date.now().toString(36) + '-' + (++idSeq)
286
+ const c = { id: id, title: title, type: 'document', content: '', version: 1, filePath: null, history: [], redoStack: [], dirty: true, updatedAt: Date.now() }
287
+ canvases.set(id, c)
288
+ activeId = id
289
+ persistMeta()
290
+ debouncedFlush()
291
+ return c
292
+ }
293
+
294
+ // ═══ AI 改动记录(喂给画布端的「跳过去给我看」)══════════════════════
295
+ // 需求(主人 2026-09-15):AI 改完文档后,画布自动跳到改动处,让主人当场核对。
296
+ //
297
+ // 为什么要在这里记:applyWrite 是所有改动的必经之路,而且它手里同时握着
298
+ // **改前**(before)与**改后**(after)两份完整内容 —— 只有这里才知道"改在哪了"。
299
+ //
300
+ // 为什么只记 source==='ai':同一条路还走用户的输入('human':界面自动保存、
301
+ // 外部回写)。那些是**主人自己**在改,跳给自己看毫无意义,还会打断输入。
302
+ // [[CCV-CHANGE-BEGIN]]
303
+ var AI_CHANGES_MAX = 20 // 队列上限,防失控(画布端按 seq 增量取,取过的就不再要)
304
+ var aiChanges = []
305
+ var aiChangeSeq = 0
306
+
307
+ // 锚点长度。为什么是 40:太短容易在正文里撞到别的地方(撞了就跳错位置),
308
+ // 太长则可能横跨被改动的内容 —— 锚点**必须改前改后都存在**才可靠,
309
+ // 所以只能从"公共前缀/后缀"里取(那部分逐字相同)。
310
+ var ANCHOR_CHARS = 40
311
+ // 新内容最多带多少字给画布端。太长对"找地方"没有帮助,只增加传输。
312
+ var ADDED_MAX = 160
313
+ // 搜索键**最少**几个字。见 widenSpan 的注释:不撑开的话,
314
+ // AI 只改一个字时搜索键就只有 1 个字,在全文里太容易撞到别处。
315
+ var MIN_SPAN = 8
316
+
317
+ // 找 before / after 的最长公共前缀与后缀,中间那段就是改动区间。
318
+ //
319
+ // 为什么不用正经的逐行 diff:AI 改话布绝大多数是**整篇 replace、实际只动几处**,
320
+ // 公共前后缀能**精确圈出**那几处。
321
+ // 它唯一的偏差是"改动分散在两处以上"时会圈得偏大(从第一处一直圈到最后一处),
322
+ // 但那种情况跳过去仍然看得见(跳到第一处),可以接受 —— 先用最简的换最稳的。
323
+ function diffSpan(before, after) {
324
+ var p = 0
325
+ var minLen = Math.min(before.length, after.length)
326
+ while (p < minLen && before.charAt(p) === after.charAt(p)) p++
327
+ var s = 0 // 从末尾往前数的公共长度
328
+ var maxS = Math.min(before.length - p, after.length - p)
329
+ while (s < maxS
330
+ && before.charAt(before.length - 1 - s) === after.charAt(after.length - 1 - s)) s++
331
+ return { start: p, end: after.length - s, beforeEnd: before.length - s }
332
+ }
333
+
334
+ // 把改动区间往两边撑开到至少 min 个字。
335
+ //
336
+ // 🔴 为什么非撑不可(实测踩到的):公共前后缀算出来的是**最小**区间。
337
+ // 「散步」→「跑步」只会圈出「散」→「跑」,因为「步」被算成公共后缀;
338
+ // 只换一个字时区间更是只有 1 个字。而画布端是拿这个区间的文字**去全文里找**的
339
+ // —— 1 个字的键到处都能撞上,它会跳到错的地方,主人看到一段没变的文字,
340
+ // 还以为 AI 改错了。**我们只需要"跳对地方",不需要"精确到字"**,
341
+ // 所以宁可多圈几个字。
342
+ function widenSpan(start, end, len, min) {
343
+ var need = min - (end - start)
344
+ if (need <= 0) return { start: start, end: end }
345
+ var ns = Math.max(0, start - Math.ceil(need / 2))
346
+ var ne = Math.min(len, end + (need - (start - ns)))
347
+ if (ne - ns < min) ns = Math.max(0, ne - min) // 右边先顶到头了,剩下的额度补给左边
348
+ return { start: ns, end: ne }
349
+ }
350
+
351
+ /**
352
+ * 记一笔「AI 把这篇文档改在哪了」。
353
+ * @returns {object|null} 记录;没变化或出错都返回 null(调用方不关心,静默)
354
+ */
355
+ function noteAiChange(c, before, after) {
356
+ try {
357
+ if (before === after) return null // 内容没变 → 不记,免得画布端白跳一次
358
+ var d = diffSpan(before, after) // 最小区间(纯计算,别在这里动手脚)
359
+ var w = widenSpan(d.start, d.end, after.length, MIN_SPAN) // 搜索键要撑开,理由见上
360
+ var rec = {
361
+ seq: ++aiChangeSeq,
362
+ at: Date.now(),
363
+ canvasId: c.id,
364
+ title: c.title,
365
+ version: c.version,
366
+ // 改动点**之前**的文字(取自公共前缀 → 改前改后都在,可靠)
367
+ head: after.slice(Math.max(0, d.start - ANCHOR_CHARS), d.start),
368
+ // 改动点**之后**的文字(取自公共后缀 → 同理)
369
+ tail: after.slice(d.end, d.end + ANCHOR_CHARS),
370
+ // ⚠️ added 是**撑开过**的,它的长度**不等于** addedChars —— 两个数用途不同:
371
+ // · added 给画布端"去全文里找地方"(越长越不容易撞,见 widenSpan)
372
+ // · addedChars 报"真实改了几个字"(老实数,给日志和判断用)
373
+ // 别把它们当成同一个数,也别据此以为撑开是 bug。
374
+ added: after.slice(w.start, w.end).slice(0, ADDED_MAX),
375
+ // 规模。注意这里是**最小**改动量,不是撑开后的长度
376
+ removedChars: d.beforeEnd - d.start,
377
+ addedChars: d.end - d.start
378
+ }
379
+ aiChanges.push(rec)
380
+ if (aiChanges.length > AI_CHANGES_MAX) aiChanges.shift()
381
+ return rec
382
+ } catch (e) { return null } // 记录失败绝不影响写入本身
383
+ }
384
+ // [[CCV-CHANGE-END]]
385
+
386
+ function applyWrite(c, content, mode, baseVersion, source) {
387
+ if (typeof baseVersion === 'number' && baseVersion !== c.version) {
388
+ return { conflict: true, currentVersion: c.version, currentContent: c.content }
389
+ }
390
+ const before = c.content
391
+ if (!mode || mode === 'replace') c.content = content
392
+ else if (mode === 'append') c.content = before + content
393
+ else if (mode === 'prepend') c.content = content + before
394
+ else return { badMode: true }
395
+ c.version += 1
396
+ c.dirty = true
397
+ c.updatedAt = Date.now()
398
+ pushHistory(c, before, c.content, source, c.version)
399
+ // 只记 AI 的改动 —— 主人自己打字/自动保存不该触发"跳过去给你看"
400
+ if (source === 'ai') {
401
+ noteAiChange(c, before, c.content)
402
+ // 🔴 AI 的改动**立刻落盘**,不等那 1.5 秒防抖。
403
+ // 为什么要区别对待:防抖是为「人在连续打字」准备的(合并成一次写),
404
+ // 而 AI 一次改动就是一次,没有可合并的东西,等 1.5 秒只有坏处 ——
405
+ // 主人改完马上去看磁盘、或让别的工具读这个文件,看到的还是上一版,
406
+ // 会以为「根本没写进去」(2026-09-15 实测报告过)。这是**静默的误导**。
407
+ // 失败不影响内存结果(内存里已经是新内容),防抖那次还会兜底重试一遍。
408
+ saveSerial(c).then(function () { return persistMeta() }).catch(function (e) {
409
+ console.error('[collab-canvas] ai 立即落盘失败(防抖那次会重试):', c.title, e && e.message)
410
+ })
411
+ }
412
+ debouncedFlush()
413
+ return { version: c.version }
414
+ }
415
+
416
+ async function loadFileDoc(filePath, title) {
417
+ const target = await fs.resolve(filePath)
418
+ let key
419
+ try { key = path.resolve(String(target)) } catch (_) { key = String(target) }
420
+ // ⚠️ 同一文件已经有画布 → 直接复用,绝不再新建一条。
421
+ // 否则每加载一次就多一条指向同一 filePath 的记录,两条各自持有内存内容:
422
+ // 旧的那条是过期的,它一旦保存就把文件覆盖回旧内容 ——
423
+ // 用户以为改好了,实际被静默回滚(2026-09-13 另一会话实测到的重复画布即此)。
424
+ let existing = null
425
+ canvases.forEach(function (x) {
426
+ if (existing || !x.filePath) return
427
+ let k2
428
+ try { k2 = path.resolve(String(x.filePath)) } catch (_) { k2 = String(x.filePath) }
429
+ if (k2 === key) existing = x
430
+ })
431
+ if (existing) {
432
+ activeId = existing.id
433
+ return existing
434
+ }
435
+ const content = await fs.readText(target)
436
+ const id = 'cv-' + Date.now().toString(36) + '-' + (++idSeq)
437
+ const t = title || baseName(filePath).replace(/\.[^.]+$/, '') || 'loaded'
438
+ const c = { id: id, title: t, type: 'document', content: content, version: 1, filePath: filePath, history: [], redoStack: [], dirty: false, updatedAt: Date.now() }
439
+ canvases.set(id, c)
440
+ activeId = id
441
+ persistMeta()
442
+ return c
443
+ }
444
+
445
+ async function setRootDoc(newRoot) {
446
+ const oldBase = docsDirBase()
447
+ rootOverride = newRoot
448
+ let migrated = 0
449
+ for (const c of Array.from(canvases.values())) {
450
+ if (c.filePath && oldBase && c.filePath.indexOf(oldBase) === 0) {
451
+ try {
452
+ const np = joinPath(joinPath(newRoot, 'canvas-docs'), baseName(c.filePath))
453
+ await writeFile(np, c.content)
454
+ c.filePath = np
455
+ migrated++
456
+ } catch (e) { console.error('[collab-canvas] migrate fail:', c.title, e && e.message) }
457
+ }
458
+ }
459
+ await persistMeta()
460
+ return { root: docsDirBase(), migrated: migrated }
461
+ }
462
+
463
+ // ==================== 06-rpc.js ====================
464
+ // Client→Host 包级私有 RPC(composition 无 harness 沙箱,仅动态系统存在)
465
+ // Phase 1(纯 Host 工具)跳过这些 RPC;若 harness 存在则注册(Phase 2 WYSIWYG 用)
466
+ if (typeof harness !== 'undefined') {
467
+ harness.handle('canvas.list', async function () {
468
+ const arr = []
469
+ canvases.forEach(function (c) {
470
+ arr.push({ id: c.id, title: c.title, type: c.type, updatedAt: c.updatedAt, hasFile: !!c.filePath })
471
+ })
472
+ return { ok: true, canvases: arr, activeId: activeId, docsDir: docsDir() }
473
+ })
474
+
475
+ harness.handle('canvas.create', async function (args) {
476
+ const title = args && typeof args.title === 'string' && args.title.trim()
477
+ if (!title) return err('E_BAD_ARGS', '需要非空 title')
478
+ const c = createCanvasDoc(title)
479
+ return { ok: true, id: c.id }
480
+ })
481
+
482
+ harness.handle('canvas.open', async function (args) {
483
+ const c = canvases.get(args && args.canvasId)
484
+ if (!c) return err('E_NOT_FOUND', '画布不存在')
485
+ activeId = c.id
486
+ return { ok: true, id: c.id, title: c.title, type: c.type, content: c.content, version: c.version, filePath: c.filePath }
487
+ })
488
+
489
+ harness.handle('canvas.edit', async function (args) {
490
+ const c = canvases.get(args && args.canvasId)
491
+ if (!c) return err('E_NOT_FOUND', '画布不存在')
492
+ if (typeof args.content !== 'string') return err('E_BAD_ARGS', 'content 必须是字符串')
493
+ const r = applyWrite(c, args.content, 'replace', typeof args.baseVersion === 'number' ? args.baseVersion : undefined, 'human')
494
+ if (r.conflict) return Object.assign(err('E_CONFLICT', '版本冲突:服务端已是 v' + r.currentVersion), { currentVersion: r.currentVersion, currentContent: r.currentContent })
495
+ let othersAfter = 0
496
+ if (typeof args.knownVer === 'number') {
497
+ for (const h of c.history) {
498
+ if (h.source === 'ai' && typeof h.version === 'number' && h.version > args.knownVer) othersAfter++
499
+ }
500
+ }
501
+ return { ok: true, version: r.version, othersAfter: othersAfter }
502
+ })
503
+
504
+ harness.handle('canvas.undo', async function (args) {
505
+ const c = canvases.get(args && args.canvasId)
506
+ 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 }
516
+ })
517
+
518
+ harness.handle('canvas.redo', async function (args) {
519
+ const c = canvases.get(args && args.canvasId)
520
+ 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 }
530
+ })
531
+
532
+ harness.handle('canvas.save', async function (args) {
533
+ const c = canvases.get(args && args.canvasId)
534
+ if (!c) return err('E_NOT_FOUND', '画布不存在')
535
+ if (!fs) return err('E_IO', 'fs 服务不可用,无法保存')
536
+ try {
537
+ const r = await saveCanvas(c, args && args.filePath)
538
+ persistMeta()
539
+ return { ok: true, filePath: r.path, relocated: r.relocated }
540
+ } catch (e) {
541
+ return err('E_IO', '写入失败:' + e.message)
542
+ }
543
+ })
544
+
545
+ harness.handle('canvas.loadFile', async function (args) {
546
+ const p = args && args.filePath
547
+ if (!p || typeof p !== 'string') return err('E_BAD_ARGS', '需要 filePath')
548
+ if (!fs) return err('E_IO', 'fs 服务不可用')
549
+ try {
550
+ const c = await loadFileDoc(p, args && args.title)
551
+ return { ok: true, canvasId: c.id, title: c.title }
552
+ } catch (e) {
553
+ return err(e.message.indexOf('ENOENT') >= 0 ? 'E_NOT_FOUND' : 'E_IO', '读取失败:' + e.message)
554
+ }
555
+ })
556
+
557
+ harness.handle('canvas.getRoot', async function () {
558
+ return { ok: true, root: docsDirBase(), override: rootOverride, docsDir: docsDir() }
559
+ })
560
+
561
+ harness.handle('canvas.setRoot', async function (args) {
562
+ const r = args && typeof args.root === 'string' ? args.root.trim() : ''
563
+ if (r.length < 3) return err('E_BAD_ARGS', '需要有效的 root 目录绝对路径')
564
+ try {
565
+ const out = await setRootDoc(r)
566
+ return { ok: true, root: out.root, migrated: out.migrated }
567
+ } catch (e) {
568
+ return err('E_IO', '设置失败:' + e.message)
569
+ }
570
+ })
571
+
572
+ harness.handle('canvas.delete', async function (args) {
573
+ const c = canvases.get(args && args.canvasId)
574
+ if (!c) return err('E_NOT_FOUND', '画布不存在')
575
+ canvases.delete(c.id)
576
+ if (activeId === c.id) {
577
+ const first = canvases.keys().next()
578
+ activeId = first.done ? null : first.value
579
+ }
580
+ await persistMeta()
581
+ return { ok: true, activeId: activeId, note: '文件保留在磁盘' + (c.filePath ? ':' + c.filePath : '(未落盘)') + ',可手动清理' }
582
+ })
583
+ }
584
+
585
+ // ==================== 07-tools.js ====================
586
+ // 模型可见动态工具(execute 直调 store,写入标记 source:'ai')
587
+ function toolOutput(v) { return JSON.stringify(v) }
588
+ function resolveCanvas(args) {
589
+ const id = args && args.canvasId ? args.canvasId : activeId
590
+ const c = id ? canvases.get(id) : undefined
591
+ if (!c) return { error: err('E_NOT_FOUND', '未找到目标画布' + (id ? ':' + id : '(当前无活跃画布,可先 canvas_create)')) }
592
+ return { c: c }
593
+ }
594
+
595
+ ctx.tools.register(defineTool({
596
+ name: 'canvas_list',
597
+ description: '列出共创话布(DSH 画布插件)中的全部画布及当前活跃画布。当用户提到"画布/共创话布/话布/canvas"时先调用它了解现状。',
598
+ parameters: {},
599
+ output: { schema: { type: 'string' }, render: function (_a, v) { return [{ type: 'text', text: v }] } },
600
+ execute: async function () {
601
+ const arr = []
602
+ canvases.forEach(function (c) { arr.push({ id: c.id, title: c.title, type: c.type, version: c.version, chars: c.content.length, hasFile: !!c.filePath }) })
603
+ return toolOutput({ ok: true, activeId: activeId, canvases: arr, docsDir: docsDir(), hint: '需要新画布用 canvas_create;读取内容用 canvas_read。' })
604
+ },
605
+ }))
606
+
607
+ ctx.tools.register(defineTool({
608
+ name: 'canvas_create',
609
+ description: '新建一个 Markdown 文档画布并设为活跃画布。用户说"开个新画布写XX/在共创话布上起草XX"时调用。',
610
+ parameters: { title: { type: 'string', required: true, description: '画布标题,同时用作默认文件名' } },
611
+ output: { schema: { type: 'string' }, render: function (_a, v) { return [{ type: 'text', text: v }] } },
612
+ execute: async function (args) {
613
+ const title = args && typeof args.title === 'string' && args.title.trim()
614
+ if (!title) return toolOutput(err('E_BAD_ARGS', 'title 必须为非空字符串'))
615
+ const c = createCanvasDoc(title)
616
+ // 会话清单登记:AI 建的画布归入浏览器最近在看的会话清单(面板轮询可见)
617
+ try { sessAdd(lastBrowserSid, c.id) } catch (_) {}
618
+ return toolOutput({ ok: true, canvasId: c.id, title: title, hint: '已创建并设为活跃画布,接着可用 canvas_write 写入内容。本轮结束前记得调用 present 声明 "canvas-docs/' + slugify(c.title) + '.md"——它会在回复末尾生成一张可点卡片,点开就是话布面板。' })
619
+ },
620
+ }))
621
+
622
+ ctx.tools.register(defineTool({
623
+ name: 'canvas_read',
624
+ description: '读取一个画布的标题与完整 Markdown 内容(含版本号)。写入前务必先读;用户问"画布里有什么"也用它。',
625
+ parameters: {
626
+ canvasId: { type: 'string', description: '画布 id,缺省读活跃画布' },
627
+ head: { type: 'number', description: '只返回前 N 个字符(大文档粗看用)' },
628
+ },
629
+ output: { schema: { type: 'string' }, render: function (_a, v) { return [{ type: 'text', text: v }] } },
630
+ execute: async function (args) {
631
+ const r = resolveCanvas(args)
632
+ if (r.error) return toolOutput(r.error)
633
+ let content = r.c.content
634
+ if (args && typeof args.head === 'number' && args.head > 0 && content.length > args.head) {
635
+ content = content.slice(0, args.head) + '…(截断,共 ' + r.c.content.length + ' 字符)'
636
+ }
637
+ return toolOutput({ ok: true, canvasId: r.c.id, title: r.c.title, version: r.c.version, content: content })
638
+ },
639
+ }))
640
+
641
+ ctx.tools.register(defineTool({
642
+ name: 'canvas_write',
643
+ description: '向画布写入 Markdown 内容(AI 编辑直接生效,用户可撤销)。replace 需先 canvas_read 取最新 baseVersion 强一致检查;append/prepend 无需 baseVersion。',
644
+ parameters: {
645
+ content: { type: 'string', required: true, description: '要写入的 Markdown 文本' },
646
+ mode: { type: 'string', description: "写入模式:'replace'(默认整文替换) | 'append' 追加到末尾 | 'prepend' 插入开头" },
647
+ canvasId: { type: 'string', description: '画布 id,缺省写活跃画布' },
648
+ baseVersion: { type: 'number', description: '期望的当前版本号;与服务端不符将拒绝以防覆盖他人修改' },
649
+ },
650
+ output: { schema: { type: 'string' }, render: function (_a, v) { return [{ type: 'text', text: v }] } },
651
+ execute: async function (args) {
652
+ if (!args || typeof args.content !== 'string') return toolOutput(err('E_BAD_ARGS', 'content 必须为字符串'))
653
+ const mode = args.mode || 'replace'
654
+ const r = resolveCanvas(args)
655
+ if (r.error) return toolOutput(r.error)
656
+ const w = applyWrite(r.c, args.content, mode, typeof args.baseVersion === 'number' ? args.baseVersion : undefined, 'ai')
657
+ if (w.badMode) return toolOutput(err('E_BAD_ARGS', "mode 仅支持 'replace'|'append'|'prepend'"))
658
+ if (w.conflict) return toolOutput(Object.assign(err('E_CONFLICT', '版本冲突:服务端已是 v' + w.currentVersion + ',请先 canvas_read 再重试'), { currentVersion: w.currentVersion }))
659
+ return toolOutput({ ok: true, canvasId: r.c.id, title: r.c.title, version: w.version, mode: mode, hint: '已写入画布「' + r.c.title + '」v' + w.version + ',用户可撤销。内容约 1.5 秒后自动落盘。本轮结束前记得调用 present 声明 "canvas-docs/' + slugify(r.c.title) + '.md"——它会在回复末尾生成一张可点卡片,点开就是话布面板。' })
660
+ },
661
+ }))
662
+
663
+ ctx.tools.register(defineTool({
664
+ name: 'canvas_save',
665
+ description: '立即保存画布到文件(自动保存之外的手动落盘)。首次保存外部项目文件或用户明说"保存"时调用。',
666
+ parameters: {
667
+ canvasId: { type: 'string', description: '画布 id,缺省保存活跃画布' },
668
+ filePath: { type: 'string', description: '目标文件路径;缺省用已关联路径或 <workspace>/canvas-docs/<标题>.md' },
669
+ },
670
+ output: { schema: { type: 'string' }, render: function (_a, v) { return [{ type: 'text', text: v }] } },
671
+ execute: async function (args) {
672
+ const r0 = resolveCanvas(args)
673
+ if (r0.error) return toolOutput(r0.error)
674
+ try {
675
+ const r = await saveCanvas(r0.c, args && args.filePath)
676
+ persistMeta()
677
+ return toolOutput({ ok: true, canvasId: r0.c.id, filePath: r.path, relocated: r.relocated, hint: r.relocated ? '注意:目标目录被文件沙箱拒绝,内容已保存到沙箱认可区 ' + r.path : '已保存。' })
678
+ } catch (e) {
679
+ return toolOutput(err('E_IO', '写入失败:' + e.message))
680
+ }
681
+ },
682
+ }))
683
+
684
+ ctx.tools.register(defineTool({
685
+ name: 'canvas_load',
686
+ description: '把一个已有的本地 Markdown 文件加载为新画布(如打开项目里的 README/方案文档进行协作改写)。',
687
+ parameters: {
688
+ filePath: { type: 'string', required: true, description: '文件绝对路径' },
689
+ title: { type: 'string', description: '画布标题,缺省用文件名' },
690
+ },
691
+ output: { schema: { type: 'string' }, render: function (_a, v) { return [{ type: 'text', text: v }] } },
692
+ execute: async function (args) {
693
+ const p = args && args.filePath
694
+ if (!p || typeof p !== 'string') return toolOutput(err('E_BAD_ARGS', 'filePath 必须为字符串'))
695
+ try {
696
+ const c = await loadFileDoc(p, args && args.title)
697
+ // 会话清单登记:AI 加载的文档归入浏览器最近在看的会话清单(面板轮询可见)
698
+ try { sessAdd(lastBrowserSid, c.id) } catch (_) {}
699
+ return toolOutput({ ok: true, canvasId: c.id, title: c.title, chars: c.content.length, hint: '已加载为画布并设为活跃。' })
700
+ } catch (e) {
701
+ return toolOutput(err(e.message.indexOf('ENOENT') >= 0 ? 'E_NOT_FOUND' : 'E_IO', '读取失败:' + e.message))
702
+ }
703
+ },
704
+ }))
705
+
706
+ ctx.tools.register(defineTool({
707
+ name: 'canvas_configure',
708
+ description: '查询或固定共创话布插件的存储根目录。传入 root 则固定(自动迁移已有画布文件并持久化),不传则仅查询当前生效目录。用户要求"把画布存到XX目录"时调用。',
709
+ parameters: {
710
+ root: { type: 'string', description: '要固定的存储根目录绝对路径;缺省仅查询' },
711
+ },
712
+ output: { schema: { type: 'string' }, render: function (_a, v) { return [{ type: 'text', text: v }] } },
713
+ execute: async function (args) {
714
+ if (args && typeof args.root === 'string' && args.root.trim().length > 2) {
715
+ try {
716
+ const out = await setRootDoc(args.root.trim())
717
+ return toolOutput({ ok: true, root: out.root, migrated: out.migrated, hint: '存储根已固定并持久化,迁移了 ' + out.migrated + ' 个画布文件。' })
718
+ } catch (e) {
719
+ return toolOutput(err('E_IO', '设置失败:' + e.message))
720
+ }
721
+ }
722
+ return toolOutput({ ok: true, root: docsDirBase(), docsDir: docsDir(), hint: '当前存储根如上;传 root 参数可固定到其他目录。' })
723
+ },
724
+ }))
725
+
726
+ ctx.tools.register(defineTool({
727
+ name: 'canvas_delete',
728
+ description: '删除一个画布记录(内存 + meta 落盘)。删除后文件保留在磁盘可手动清理;用户说"删除/清理某个画布"时调用。',
729
+ parameters: {
730
+ canvasId: { type: 'string', required: true, description: '要删除的画布 id(来自 canvas_list)' },
731
+ },
732
+ output: { schema: { type: 'string' }, render: function (_a, v) { return [{ type: 'text', text: v }] } },
733
+ execute: async function (args) {
734
+ const id = args && typeof args.canvasId === 'string' ? args.canvasId : ''
735
+ if (!id) return toolOutput(err('E_BAD_ARGS', 'canvasId 必须为字符串'))
736
+ const c = canvases.get(id)
737
+ if (!c) return toolOutput(err('E_NOT_FOUND', '画布不存在:' + id))
738
+ canvases.delete(id)
739
+ if (activeId === id) {
740
+ const first = canvases.keys().next()
741
+ activeId = first.done ? null : first.value
742
+ }
743
+ try { await persistMeta() } catch (e) { console.error('[collab-canvas] delete persist failed:', e.message) }
744
+ return toolOutput({ ok: true, canvasId: id, activeId: activeId, note: '文件保留在磁盘' + (c.filePath ? ':' + c.filePath : '(未落盘)') + ',可手动清理' })
745
+ },
746
+ }))
747
+
748
+ // ═══ canvas_locate:把「这段原文到底是哪一处」讲清楚 ═══════════════
749
+ // 为什么要有它(2026-09-14 主人拍板):
750
+ // @话布选区 的引用本身只有「路径 + 行号 + 原文」两行。同一段原文在文档里
751
+ // 出现多次时,**模型没法只凭行号认出是哪一处** —— 而把"前后文坐标"塞进
752
+ // 引用正文既长又难读(主人原话:不够优雅),模型照样得自己数。
753
+ // 所以改成"模型按需来问":它想问的时候问一次,我们当场算给它。
754
+ // 顺带的好处:这里读的是**当前**文档,所以"这段引用是不是已经失效"也一并答了。
755
+ //
756
+ // [[CCV-LOCATE-BEGIN]] —— 下面到 END 之间是这块功能的全部代码。
757
+ // tools/test-host-locate.cjs 靠这两个标记把这段**真代码**切出来单独跑,
758
+ // 不是照抄一份来测。挪动或删除标记会让那份测试失效。
759
+ function occIndexes(text, needle) {
760
+ const out = []
761
+ let i = 0
762
+ while ((i = text.indexOf(needle, i)) >= 0) { out.push(i); i += 1 } // 允许重叠
763
+ return out
764
+ }
765
+ // 某一处在 Markdown 源码里的行号 / 列号(都从 1 数)。
766
+ function lineColOf(text, idx) {
767
+ const before = text.slice(0, idx)
768
+ return { line: before.split('\n').length, col: idx - (before.lastIndexOf('\n') + 1) + 1 }
769
+ }
770
+ // 摘要里出现换行会把结构拆散,换成看得见的符号;长度不变、意思不丢。
771
+ function asOneLine(s) { return String(s).replace(/\n/g, '↵') }
772
+
773
+ // 按 canvasId / path 找文档;都没给就用活跃画布。
774
+ // 引用里给的是**文件路径**,所以要支持按路径匹配。
775
+ function findCanvasByArg(args) {
776
+ const id = args && typeof args.canvasId === 'string' ? args.canvasId : ''
777
+ if (id) return canvases.get(id) || null
778
+ const raw = args && typeof args.path === 'string' ? args.path : ''
779
+ if (raw) {
780
+ const norm = (s) => String(s).replace(/\\/g, '/').replace(/^"+|"+$/g, '')
781
+ const want = norm(raw)
782
+ let hit = null
783
+ canvases.forEach(function (c) { if (!hit && c.filePath && norm(c.filePath) === want) hit = c })
784
+ if (hit) return hit
785
+ // 兜底:模型可能只给了文件名的一部分 —— 用标题去认
786
+ canvases.forEach(function (c) {
787
+ if (!hit && c.title && want.indexOf(norm(c.title).replace(/\.md$/i, '')) >= 0) hit = c
788
+ })
789
+ if (hit) return hit
790
+ }
791
+ return activeId ? canvases.get(activeId) || null : null
792
+ }
793
+
794
+ ctx.tools.register(defineTool({
795
+ name: 'canvas_locate',
796
+ description: '在话布文档里精确找出「一段原文」所处的位置(第几处、每处长什么样),'
797
+ + '并在原文已不在文档里时明确告知这段引用已经失效。'
798
+ + '⚠️ 用户用 @话布选区 引用了一段文字、且引用标签上标着「多处」时,动手改之前必须先调用它确认是哪一处;'
799
+ + '标签里的「第 N 处」直接传 ordinal 就能定住(它比行号可靠 —— 同一行里可能有好几处一模一样的文字)。'
800
+ + '没标「多处」时也可以用它复核。原文重复时不要自己挑一处改 —— 改错地方比不回答更糟。',
801
+ parameters: {
802
+ text: { type: 'string', required: true, description: '要定位的原文(引用里「」中的内容,请一字不差地照抄)' },
803
+ canvasId: { type: 'string', description: '画布 id;缺省用活跃画布' },
804
+ path: { type: 'string', description: '文档路径(引用里的 @ 路径);给了它就不必给 canvasId' },
805
+ line: { type: 'number', description: '引用标签里给的第几行,用于优先核对' },
806
+ ordinal: { type: 'number', description: '引用标签里给的「第 N 处」;给了它就直接定位到这一处,优先于 line' },
807
+ },
808
+ output: { schema: { type: 'string' }, render: function (_a, v) { return [{ type: 'text', text: v }] } },
809
+ execute: async function (args) {
810
+ const text = args && typeof args.text === 'string' ? args.text : ''
811
+ if (!text) return toolOutput(err('E_BAD_ARGS', 'text 必须为非空字符串(引用里「」中的那段原文)'))
812
+ const c = findCanvasByArg(args)
813
+ if (!c) return toolOutput(err('E_NOT_FOUND', '没找到对应的话布文档;可先用 canvas_list 看看有哪些'))
814
+ const content = String(c.content || '')
815
+ const idxs = occIndexes(content, text)
816
+
817
+ // 找不到 → 引用已经失效。这时候**绝不能**让它去找"相似内容"照着改。
818
+ if (!idxs.length) {
819
+ return toolOutput({
820
+ ok: false,
821
+ error: { code: 'E_GONE', message: '这段原文已经不在《' + c.title + '》里了' },
822
+ canvasId: c.id, title: c.title, version: c.version,
823
+ hint: '这段引用很可能已经失效(用户发出引用之后文档被改过)。'
824
+ + '请让用户重新划一次要引用的段落,**不要**去找相似的内容按猜测改。',
825
+ })
826
+ }
827
+
828
+ const askedLine = args && typeof args.line === 'number' ? args.line : -1
829
+ const askedOrd = args && typeof args.ordinal === 'number' ? args.ordinal : -1
830
+ const CTX = 20 // 每处前后各带多少字
831
+ const MAX = 8 // 最多列几处,避免输出爆炸
832
+ const spots = idxs.map(function (idx) {
833
+ const lc = lineColOf(content, idx)
834
+ const a = Math.max(0, idx - CTX)
835
+ const b = Math.min(content.length, idx + text.length + CTX)
836
+ return {
837
+ line: lc.line,
838
+ col: lc.col,
839
+ excerpt: (a > 0 ? '…' : '') + asOneLine(content.slice(a, idx))
840
+ + '【' + asOneLine(text) + '】'
841
+ + asOneLine(content.slice(idx + text.length, b)) + (b < content.length ? '…' : ''),
842
+ likelyTarget: false, // 下面统一算:只有**恰好一处**吻合行号时才置 true
843
+ }
844
+ })
845
+ // 定位优先级:
846
+ // ① 引用给的「第 N 处」最可靠 —— 同一行里可能有好几处一模一样的文字,行号根本分不开
847
+ // ② 退而用行号,且只有**恰好一处**吻合时才认
848
+ // ③ 都定不住 → 一处都不标,把每一处摆出来让用户确认
849
+ if (askedOrd > 0 && askedOrd <= spots.length) {
850
+ spots[askedOrd - 1].likelyTarget = true
851
+ } else {
852
+ const nearIdx = []
853
+ if (askedLine > 0) spots.forEach(function (s, i) { if (Math.abs(s.line - askedLine) <= 2) nearIdx.push(i) })
854
+ if (nearIdx.length === 1) spots[nearIdx[0]].likelyTarget = true
855
+ }
856
+ const shown = spots.slice(0, MAX)
857
+ const near = spots.filter(function (s) { return s.likelyTarget })
858
+ let hint
859
+ if (spots.length === 1) {
860
+ hint = '原文在文中只有这一处,就是它。'
861
+ } else if (askedOrd > 0 && askedOrd <= spots.length) {
862
+ hint = '按引用标签给的「第 ' + askedOrd + ' 处」(全文共 ' + spots.length + ' 处),目标就是标了 likelyTarget 的那一处;'
863
+ + '请照 excerpt 核对一次再动手。'
864
+ } else if (near.length === 1) {
865
+ hint = '共 ' + spots.length + ' 处;与引用给出的第 ' + askedLine + ' 行对得上的那一处已标 likelyTarget,多半就是它 ——'
866
+ + '但仍请照 excerpt 核对一次再动手。'
867
+ } else {
868
+ hint = '共 ' + spots.length + ' 处,仅凭行号分不出来。请把每处的 excerpt 摆给用户,让他确认改哪一处,不要自己挑一处改。'
869
+ }
870
+ return toolOutput({
871
+ ok: true,
872
+ canvasId: c.id, title: c.title, version: c.version,
873
+ total: spots.length, shown: shown.length,
874
+ note: spots.length > shown.length ? '只列了前 ' + shown.length + ' 处。' : '',
875
+ lineNote: '这里的行号按 Markdown 源码数;引用标签里的行号按编辑器渲染结果数,两者可能有偏差,请以 excerpt 为准。',
876
+ spots: shown,
877
+ hint: hint,
878
+ })
879
+ },
880
+ }))
881
+ // [[CCV-LOCATE-END]]
882
+
883
+ // ==================== 08-prompt.js ====================
884
+ // 模型使用指南 prompt section
885
+ try {
886
+ const sp = ctx.get('systemPrompt')
887
+ if (sp && typeof sp.section === 'function') {
888
+ sp.section({
889
+ id: 'collab-canvas-usage',
890
+ title: '话布工具使用指南',
891
+ order: 200,
892
+ text: [
893
+ '用户提到"话布/画布/共创话布/canvas"时,使用 canvas_* 工具组操作话布,不要把长内容直接贴在聊天里。',
894
+ 'canvas_write 的 replace 模式覆盖全文,写入前必须先 canvas_read;追加用 append 模式(无需 baseVersion)。',
895
+ 'AI 写入直接生效,用户可在话布面板撤销;大改动建议分段 append。',
896
+ '用户未明说"保存"时不主动 canvas_save(自动保存已在后台进行)。',
897
+ '引用话布内容进对话时只摘必要片段。',
898
+ // ⚠️ 这里刻意不再规定「在回复末尾手写一行打开入口」。
899
+ // 实测两次:只要写成「满足条件 Y 时输出 X」的约定,模型就会把它当仪式执行
900
+ // —— 记住「每轮末尾加一行」,条件被跳过。改措辞救不了(收紧后仍复发)。
901
+ // 现在入口完全由工具驱动:调了 present 才有卡片,不调就没有,
902
+ // 没有可被误触发的余地。正文里提到标题时,客户端本来就会自动转成链接。
903
+ '【可点入口】本轮确实创建或修改了话布文档时,调用 present 声明 "canvas-docs/<标题>.md"(标题用 slugify 规则:空格换 -、去掉路径非法字符;中文标题恒等)。它会在回复末尾生成一张可点卡片,点开即话布面板。',
904
+ '不要在正文里手写「打开话布」「点击查看」之类的入口行——入口只由 present 卡片承担,正文不提。',
905
+ ].join('\n'),
906
+ })
907
+ console.log('[collab-canvas]', 'prompt section registered')
908
+ }
909
+ } catch (e) {
910
+ console.error('[collab-canvas] prompt section failed:', e && e.message)
911
+ }
912
+
913
+ // ==================== 09-http.js ====================
914
+ // 画布 HTTP 端点——供浏览器编辑器通过 fetch 读写画布数据
915
+ // 路由:GET /api/canvas/list|read?id=xxx,POST /api/canvas/write|save|create
916
+ // 用 ctx.inject(['webServer']) 确保 webServer 服务就绪后再注册
917
+
918
+ // ─── 会话独立清单:每个会话记录自己新建/打开过的画布 ───
919
+ // 元数据存 canvas-docs/.sessions/<sid>.json;文档 md 文件仍在共享池 canvas-docs/
920
+ var sessStore = new Map() // sid -> { activeId, ids: [] }
921
+ var lastBrowserSid = 'default' // 浏览器最近在看的会话键(AI 工具创建的画布归入它的清单)
922
+ // 一次性迁移的持久化标记(.sessions/.migrated):迁移执行过一次就永久生效,
923
+ // 重启不再把全局画布重新挂回清单(之前标记是内存变量,重启清零导致删掉的清单复原)
924
+ function sessMigratedAlready() {
925
+ try { return nodefs.existsSync(joinPath(sessDirPath(), '.migrated')) } catch (_) { return true }
926
+ }
927
+ function sessMarkMigrated() {
928
+ try { nodefs.writeFileSync(joinPath(sessDirPath(), '.migrated'), String(Date.now())) } catch (_) {}
929
+ }
930
+ var sessMigrated = false
931
+ function sessDirPath() {
932
+ const d = joinPath(docsDir(), '.sessions')
933
+ try { nodefs.mkdirSync(d, { recursive: true }) } catch (_) {}
934
+ return d
935
+ }
936
+ function sessSafe(sid) { return String(sid || '').replace(/[^\w\u4e00-\u9fa5-]/g, '').slice(0, 80) || 'default' }
937
+ function sessLoad(sid) {
938
+ try {
939
+ const j = JSON.parse(nodefs.readFileSync(joinPath(sessDirPath(), sessSafe(sid) + '.json'), 'utf8'))
940
+ if (j && Array.isArray(j.ids)) return { activeId: j.activeId || null, ids: j.ids.filter(x => canvases.has(x)) }
941
+ } catch (_) {}
942
+ return { activeId: null, ids: [] }
943
+ }
944
+ function sessPersist(sid, st) {
945
+ try { nodefs.writeFileSync(joinPath(sessDirPath(), sessSafe(sid) + '.json'), JSON.stringify({ activeId: st.activeId, ids: st.ids }, null, 2)) } catch (_) {}
946
+ }
947
+ function sessState(sid) {
948
+ sid = sessSafe(sid)
949
+ if (!sessStore.has(sid)) {
950
+ let st = sessLoad(sid)
951
+ // 一次性迁移:旧全局清单挂到重启后第一个使用的会话名下。
952
+ // 标记用持久化文件(.sessions/.migrated),内存标记会在重启后丢失、
953
+ // 导致每次重启都把全局画布重新挂回清单(用户删了又复现的根因)
954
+ if (!st.ids.length && canvases.size && !sessMigratedAlready()) {
955
+ st = { activeId: activeId, ids: Array.from(canvases.keys()) }
956
+ sessMarkMigrated()
957
+ sessPersist(sid, st)
958
+ console.log('[collab-canvas] 会话清单迁移:', sid, '<-', st.ids.join(','))
959
+ }
960
+ sessStore.set(sid, st)
961
+ }
962
+ return sessStore.get(sid)
963
+ }
964
+ function sessAdd(sid, id) {
965
+ const st = sessState(sid)
966
+ if (st.ids.indexOf(id) < 0) { st.ids.push(id); sessPersist(sid, st) }
967
+ if (st.activeId !== id) { st.activeId = id; sessPersist(sid, st) }
968
+ }
969
+ function sessRemove(sid, id) {
970
+ const st = sessState(sid)
971
+ st.ids = st.ids.filter(x => x !== id)
972
+ if (st.activeId === id) st.activeId = null
973
+ sessPersist(sid, st)
974
+ }
975
+
976
+ 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
+ }
998
+ // GET /api/canvas/list?sid=xxx —— 只返回该会话清单里的画布
999
+ // ?scope=all —— 返回全部画布(跨会话)。客户端做「标题 → 可点链接」
1000
+ // 匹配时用:会话清单只含「本会话开过/建过的」,
1001
+ // 没登记过的文档标题会匹配不到、转不成链接。
1002
+ // 每条都带 path:md 文件的**真实绝对路径**(正斜杠)。客户端生成 @ 引用、
1003
+ // 显示位置都要用真实路径,不能自己拼相对路径 —— 存储根被 rootOverride
1004
+ // 固定到别处后,相对路径会指向工作区里的残留目录。
1005
+ ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/list', handler: async (req, res) => {
1006
+ const url = new URL(req.url, 'http://x')
1007
+ if (url.searchParams.get('scope') === 'all') {
1008
+ 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' })
1011
+ return
1012
+ }
1013
+ const sid = sessSafe(url.searchParams.get('sid'))
1014
+ lastBrowserSid = sid
1015
+ const st = sessState(sid)
1016
+ 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 })
1019
+ }}))
1020
+ // GET /api/canvas/read?id=xxx —— 打开过就记入该会话清单
1021
+ ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/read', handler: async (req, res) => {
1022
+ const url = new URL(req.url, 'http://x')
1023
+ const id = url.searchParams.get('id')
1024
+ 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) })
1029
+ }}))
1030
+ // GET /api/canvas/changes?since=N —— 画布端轮询「AI 刚改了哪儿」
1031
+ // 为什么用轮询而不是推送:客户端已经有现成的取数习惯(读文档、报日志都是普通请求),
1032
+ // 加一条推送通道要新增一套连接生命周期管理,收益不值当。
1033
+ // 轮询间隔 1.2 秒 —— AI 改完到主人看向屏幕本来就有间隔,这点延迟无感。
1034
+ // 只回 since **之后**的新条目:画布端记住自己处理到的 seq,取过的不会再要一次。
1035
+ ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/changes', handler: async (req, res) => {
1036
+ const url = new URL(req.url, 'http://x')
1037
+ const raw = url.searchParams.get('since')
1038
+ const since = (raw === null || raw === '') ? 0 : parseInt(raw, 10)
1039
+ if (isNaN(since)) { error(res, 400, { ok: false, error: 'since 必须是数字' }); return }
1040
+ const out = aiChanges.filter((x) => x.seq > since)
1041
+ json(res, { ok: true, latest: aiChangeSeq, changes: out })
1042
+ }}))
1043
+ // POST /api/canvas/selref/pin —— 客户端报「你刚才划的是第几处」
1044
+ // 为什么需要:引用正文里**已经不写坐标了**(2026-09-15 主人要求对话里那张
1045
+ // 卡片只留文件名),而"第几处"靠的是**划选那一刻的 DOM 位置** —— 只有浏览器
1046
+ // 那边知道,服务端自己无论如何算不出。所以单独送一份过来,
1047
+ // 供 host 侧的「位置主动注入」(13-selref-context.js)使用。
1048
+ // 只存内存、不落盘:它是**当下的**输入,重启后就该丢。
1049
+ // 实现在 13-selref-context.js(那边有 [[CCV-SELCTX]] 标记,测试切得出来)。
1050
+ ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/selref/pin', handler: async (req, res) => {
1051
+ try {
1052
+ if (req.method !== 'POST') { error(res, 405, { ok: false, error: '只收 POST' }); return }
1053
+ const body = await readBody(req)
1054
+ const taken = ccvPinAdd(body)
1055
+ json(res, { ok: taken, n: ccvPins.length })
1056
+ } catch (e) { error(res, 400, { ok: false, error: e.message }) }
1057
+ }}))
1058
+ // POST /api/canvas/write
1059
+ ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/write', handler: async (req, res) => {
1060
+ try {
1061
+ const body = await readBody(req)
1062
+ const id = body.id || activeId
1063
+ const c = id ? canvases.get(id) : null
1064
+ if (!c) { error(res, 404, { ok: false, error: '画布不存在' }); return }
1065
+ const result = applyWrite(c, body.content, 'replace', body.knownVer, 'human')
1066
+ // ⚠️ 冲突必须显式回传。applyWrite 检出并发冲突时返回
1067
+ // { conflict, currentVersion, currentContent },此时 result.version 是 undefined;
1068
+ // 若照原样 json({ ok: true, version: result.version }),
1069
+ // undefined 会被 JSON.stringify 丢掉 → 响应变成 {"ok":true} ——
1070
+ // 冲突被伪装成成功,浏览器编辑器无从察觉,只能继续用旧内容写。
1071
+ if (result && result.conflict) {
1072
+ json(res, { ok: false, conflict: true, currentVersion: result.currentVersion, currentContent: result.currentContent })
1073
+ return
1074
+ }
1075
+ json(res, { ok: true, version: result.version })
1076
+ } catch (e) { error(res, 500, { ok: false, error: e.message }) }
1077
+ }}))
1078
+ // POST /api/canvas/create?sid=xxx —— 新建即记入当前会话清单
1079
+ ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/create', handler: async (req, res) => {
1080
+ try {
1081
+ const url = new URL(req.url, 'http://x')
1082
+ const body = await readBody(req)
1083
+ 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 }) }
1087
+ }}))
1088
+ // ─── 客户端诊断日志(排障用)─────────────────────────
1089
+ // 环境里用户开不了浏览器控制台,客户端把关键判定点 POST 到这里,
1090
+ // 落盘到 %TEMP%/ccv-selbar.log,不用 console 也能复盘当时发生了什么。
1091
+ const DEBUG_LOG = (process.env.TEMP || process.env.TMPDIR || '.') + '/ccv-selbar.log'
1092
+ let _dbg = []
1093
+ const DBG_MAX = 400
1094
+ function ts() {
1095
+ const d = new Date()
1096
+ const p = function (n) { return String(n).padStart(2, '0') }
1097
+ return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()) + '.' + String(d.getMilliseconds()).padStart(3, '0')
1098
+ }
1099
+ function dbgWrite() {
1100
+ try {
1101
+ if (typeof nodefs !== 'undefined' && nodefs && nodefs.writeFileSync) {
1102
+ nodefs.writeFileSync(DEBUG_LOG, _dbg.join('\n') + '\n', 'utf8')
1103
+ }
1104
+ } catch (e) { /* 落盘失败不影响主流程 */ }
1105
+ }
1106
+ ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/log', handler: async (req, res) => {
1107
+ try {
1108
+ if (req.method === 'POST') {
1109
+ const body = await readBody(req)
1110
+ const lines = Array.isArray(body.lines) ? body.lines : [String(body.line || '')]
1111
+ lines.forEach(function (l) { _dbg.push(ts() + ' ' + String(l)) })
1112
+ if (_dbg.length > DBG_MAX) _dbg = _dbg.slice(_dbg.length - DBG_MAX)
1113
+ dbgWrite()
1114
+ json(res, { ok: true, n: _dbg.length, file: DEBUG_LOG })
1115
+ } else {
1116
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
1117
+ res.end(_dbg.join('\n') + '\n')
1118
+ }
1119
+ } catch (e) { error(res, 500, { ok: false, error: e.message }) }
1120
+ }}))
1121
+ // ─── 打开文档所在文件夹 ─────────────────────────────
1122
+ // 客户端「📁」按钮调用;host 是 Node 主进程,用 explorer/open/xdg-open
1123
+ // 在系统文件管理器里打开当前画布文档所在的目录。
1124
+ // 注意:不要用 fs.resolve —— 它返回的是沙箱内部标记,不是文件系统路径,
1125
+ // explorer 收到无效参数会默认打开「文档」库。这里用 path 模块自己归一化。
1126
+ ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/open-folder', handler: async (req, res) => {
1127
+ const c = activeId ? canvases.get(activeId) : null
1128
+ const raw = (c && c.filePath) ? path.dirname(c.filePath) : docsDir()
1129
+ let dir = path.isAbsolute(raw) ? raw : path.join(docsDirBase(), raw)
1130
+ dir = dir.replace(/[\\/]+$/, '')
1131
+ const plat = process.platform
1132
+ // explorer 对 / 与 \ 混用的路径解析不可靠(session cwd 常带正斜杠),
1133
+ // Windows 下一律归一成反斜杠 + path.win32.normalize
1134
+ if (plat === 'win32') dir = path.win32.normalize(dir.replace(/\//g, '\\'))
1135
+ const info = { dir: dir, raw: raw, exists: null, cwd: null, platform: plat, strategy: null, pid: null, err: null, exit: null }
1136
+ try { info.exists = nodefs.existsSync(dir) } catch (e) { info.err = 'existsSync: ' + (e && e.message) }
1137
+ try { info.cwd = process.cwd() } catch (e) { info.err = 'cwd: ' + (e && e.message) }
1138
+ try {
1139
+ if (plat === 'win32') {
1140
+ // explorer 从带 stdio 管道的子进程里启动时,偶尔会忽略参数、打开默认目录。
1141
+ // 用 detached + stdio:'ignore' 起,是最稳的传法。
1142
+ const child = spawn('explorer', [dir], { detached: true, stdio: 'ignore' })
1143
+ child.unref()
1144
+ info.strategy = 'spawn-explorer-detached'
1145
+ info.pid = child.pid
1146
+ child.on('error', function (e) { info.err = 'spawn error: ' + (e && e.message) })
1147
+ child.on('exit', function (code) { info.exit = code })
1148
+ } else {
1149
+ const child = spawn(plat === 'darwin' ? 'open' : 'xdg-open', [dir], { detached: true, stdio: 'ignore' })
1150
+ child.unref()
1151
+ info.strategy = 'spawn-open'
1152
+ info.pid = child.pid
1153
+ child.on('error', function (e) { info.err = 'spawn error: ' + (e && e.message) })
1154
+ }
1155
+ } catch (e) {
1156
+ info.err = 'spawn throw: ' + (e && e.message)
1157
+ try {
1158
+ exec(plat === 'win32' ? 'cmd /c start "" "' + dir + '"' : 'xdg-open "' + dir + '"', function (e2) {
1159
+ info.strategy = 'cmd-start-fallback'
1160
+ info.err = e2 ? ('cmd start: ' + e2.message) : null
1161
+ })
1162
+ } catch (e3) { info.err = 'fallback throw: ' + (e3 && e3.message) }
1163
+ }
1164
+ setTimeout(function () {
1165
+ _dbg.push(ts() + ' open-folder ' + JSON.stringify(info))
1166
+ if (_dbg.length > DBG_MAX) _dbg = _dbg.slice(_dbg.length - DBG_MAX)
1167
+ dbgWrite()
1168
+ }, 1200)
1169
+ 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 })
1171
+ }}))
1172
+ console.log('[collab-canvas] HTTP endpoints registered: /api/canvas/* (debug log -> ' + DEBUG_LOG + ')')
1173
+ }
1174
+
1175
+ // ==================== 10-editor-code.js ====================
1176
+ // 这里原本有一段 12,999 字符的 EDITOR_PANEL_JS 字符串:早期"浮层面板式编辑器"的
1177
+ // 整份浏览器端代码(自带一套 mdToHtml/htmlToMd、划词栏,还有一段把「话布」按钮
1178
+ // 硬塞进侧栏设置区的 findSettingsArea + setInterval 逻辑)。
1179
+ // 2026-09-12 代码体检确认:全包零引用(只有定义、没有任何读取),实际对外提供的是
1180
+ // lib/client.js,所以整段是纯死代码,已删除。
1181
+ // 需要考古可查:git 历史中的 src/host/10-editor-code.js(v0.3.0 及以前)
1182
+
1183
+ // ==================== 11-canvas-extras.js ====================
1184
+ // 画布扩展端点:外部文档拖拽 / 文件登记表 / 删除 / 模块源码服务
1185
+ // 依赖同作用域:docsDirBase, docsDir, canvases, createCanvasDoc, applyWrite,
1186
+ // saveCanvas, persistMeta, nodefs, path(均由其他模块提供)
1187
+ 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
+
1211
+ // 会话工作区目录(@ 文件选择器索引的目录)
1212
+ function sessionWorkspaceDir() {
1213
+ try {
1214
+ const svc = ctx.get('sessions')
1215
+ const arr = svc && svc.list ? svc.list() : []
1216
+ for (const s of arr) {
1217
+ const holder = s && (s.header || s.meta || s)
1218
+ const cwd = holder && holder.cwd
1219
+ if (typeof cwd === 'string' && cwd.length > 2) return cwd
1220
+ }
1221
+ } catch (e) { console.error('[collab-canvas] session-cwd probe failed:', e && e.message) }
1222
+ return docsDirBase()
1223
+ }
1224
+
1225
+ // ─── 文件登记表:让拖拽按“真实路径”引用外部文档 ───
1226
+ const REG_CONFIG_FILE = () => path.join(docsDirBase(), 'file-registry.json')
1227
+ const REG_SKIP_DIRS = new Set(['node_modules', '.git', '$RECYCLE.BIN', 'System Volume Information'])
1228
+ const REG_MAX_FILES = 150000
1229
+ const regFolders = []
1230
+ const regIndex = new Map() // folder -> Map(basename -> [fullPath])
1231
+ const regWatchers = new Map()
1232
+ let regTotal = 0
1233
+
1234
+ function regLoadConfig() {
1235
+ try {
1236
+ const f = REG_CONFIG_FILE()
1237
+ if (nodefs.existsSync(f)) {
1238
+ const j = JSON.parse(nodefs.readFileSync(f, 'utf8'))
1239
+ if (Array.isArray(j.folders)) return j.folders.filter(p => typeof p === 'string' && path.isAbsolute(p))
1240
+ }
1241
+ } catch (e) { console.error('[collab-canvas] registry config read failed:', e && e.message) }
1242
+ return []
1243
+ }
1244
+ function regSaveConfig(folders) {
1245
+ try {
1246
+ 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) }
1248
+ }
1249
+ function regScanFolder(folder) {
1250
+ const map = new Map()
1251
+ let count = 0
1252
+ function walk(dir, depth) {
1253
+ if (depth > 16 || count > REG_MAX_FILES) return
1254
+ let entries = []
1255
+ try { entries = nodefs.readdirSync(dir, { withFileTypes: true }) } catch (e) { return }
1256
+ for (const ent of entries) {
1257
+ try {
1258
+ if (ent.isSymbolicLink()) continue
1259
+ const full = path.join(dir, ent.name)
1260
+ if (ent.isDirectory()) {
1261
+ if (REG_SKIP_DIRS.has(ent.name)) continue
1262
+ walk(full, depth + 1)
1263
+ } else if (ent.isFile()) {
1264
+ count++
1265
+ if (count > REG_MAX_FILES) return
1266
+ const arr = map.get(ent.name) || []
1267
+ arr.push(full)
1268
+ map.set(ent.name, arr)
1269
+ }
1270
+ } catch (_) {}
1271
+ }
1272
+ }
1273
+ walk(folder, 0)
1274
+ return map
1275
+ }
1276
+ function regRescanFolder(folder) {
1277
+ try {
1278
+ const map = regScanFolder(folder)
1279
+ let n = 0
1280
+ const sigs = []
1281
+ map.forEach(arr => { n += arr.length; arr.forEach(p => sigs.push(p)) })
1282
+ sigs.sort()
1283
+ const sig = n + '|' + sigs.join(',')
1284
+ // 无变化的重扫(OneDrive 触碰文件、临时文件增删)静默处理,只留真正变化日志
1285
+ const changed = sig !== regLastSig.get(folder)
1286
+ regLastSig.set(folder, sig)
1287
+ regIndex.set(folder, map)
1288
+ regTotal = 0
1289
+ regIndex.forEach(m => { m.forEach(arr => { regTotal += arr.length }) })
1290
+ 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) }
1292
+ }
1293
+ const regLastSig = new Map() // folder -> 上次索引签名(文件数+路径集),无变化的重扫静默处理
1294
+ const regRescanTimers = new Map()
1295
+ function regScheduleRescan(folder) {
1296
+ if (regRescanTimers.has(folder)) return
1297
+ const t = setTimeout(function () { regRescanTimers.delete(folder); regRescanFolder(folder) }, 3000)
1298
+ regRescanTimers.set(folder, t)
1299
+ }
1300
+ function regWatch(folder) {
1301
+ if (regWatchers.has(folder)) return
1302
+ try {
1303
+ const w = nodefs.watch(folder, { recursive: true }, function () { regScheduleRescan(folder) })
1304
+ w.on('error', function () {})
1305
+ regWatchers.set(folder, w)
1306
+ } catch (e) { console.error('[collab-canvas] registry watch failed:', folder, e && e.message) }
1307
+ }
1308
+ function regUnwatch(folder) {
1309
+ const w = regWatchers.get(folder)
1310
+ if (w) { try { w.close() } catch (_) {} regWatchers.delete(folder) }
1311
+ }
1312
+ // 空登记表自愈:配置为空/被清空时,自动补登记会话工作区(拖拽最常见来源)
1313
+ function regSeedIfEmpty() {
1314
+ if (regFolders.length) return false
1315
+ try {
1316
+ let cfg = regLoadConfig()
1317
+ if (!cfg.length) cfg = [sessionWorkspaceDir()]
1318
+ regFolders.push(...cfg)
1319
+ cfg.forEach(function (f) { regRescanFolder(f); regWatch(f) })
1320
+ console.log('[collab-canvas] registry: 空登记表自动补登记 ->', cfg.join(' | '))
1321
+ return true
1322
+ } catch (_) { return false }
1323
+ }
1324
+
1325
+ const UPLOAD_LIMIT = 100 * 1024 * 1024
1326
+
1327
+ // /api/canvas/registry —— GET 查看登记文件夹与索引规模;POST 设置登记文件夹
1328
+ //(须存在的绝对路径目录)。注意:DSH exact 路由同路径只允许注册一次,
1329
+ // GET/POST 必须合并在同一个 handler 里按 method 分流
1330
+ ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/registry', handler: async (req, res) => {
1331
+ try {
1332
+ if (req.method === 'POST') {
1333
+ const body = await readBody(req)
1334
+ const folders = Array.isArray(body.folders) ? body.folders.map(s => String(s).trim()).filter(Boolean) : []
1335
+ 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 }
1337
+ const uniq = Array.from(new Set(folders))
1338
+ regFolders.length = 0
1339
+ regFolders.push(...uniq)
1340
+ regSaveConfig(regFolders)
1341
+ Array.from(regIndex.keys()).forEach(function (f) { if (!regFolders.includes(f)) regIndex.delete(f) })
1342
+ Array.from(regWatchers.keys()).forEach(function (f) { if (!regFolders.includes(f)) regUnwatch(f) })
1343
+ uniq.forEach(function (f) {
1344
+ regWatch(f)
1345
+ setTimeout(function () { regRescanFolder(f) }, 0)
1346
+ })
1347
+ json(res, { ok: true, folders: regFolders.slice(), files: regTotal, note: '索引构建中' })
1348
+ return
1349
+ }
1350
+ json(res, { ok: true, folders: regFolders.slice(), files: regTotal })
1351
+ } catch (e) { error(res, 500, { ok: false, error: e.message }) }
1352
+ }}))
1353
+ // GET /api/canvas/locate?name=xxx —— 按文件名查登记索引,返回真实路径
1354
+ //(rel = 会话工作区相对路径;docsRel = 画布文档目录相对路径,形如 canvas-docs/xxx.md)
1355
+ // 查询前先做空登记表自愈,避免“忘了登记 → 永远查不到”的死循环
1356
+ ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/locate', handler: async (req, res) => {
1357
+ try {
1358
+ regSeedIfEmpty()
1359
+ const url = new URL(req.url, 'http://x')
1360
+ const name = url.searchParams.get('name') || ''
1361
+ const ws = sessionWorkspaceDir().replace(/[\\/]+$/, '')
1362
+ const docsDirN = docsDir().replace(/[\\/]+$/, '')
1363
+ const matches = []
1364
+ regIndex.forEach(function (map) {
1365
+ const arr = map.get(name)
1366
+ if (arr) arr.forEach(function (p) {
1367
+ const norm = p.replace(/\//g, '\\')
1368
+ let rel = null, docsRel = null
1369
+ const low = norm.toLowerCase()
1370
+ if (low.indexOf(ws.toLowerCase() + '\\') === 0) rel = norm.slice(ws.length + 1).replace(/\\/g, '/')
1371
+ if (low.indexOf(docsDirN.toLowerCase() + '\\') === 0) docsRel = 'canvas-docs/' + norm.slice(docsDirN.length + 1).replace(/\\/g, '/')
1372
+ matches.push({ path: norm, rel: rel, docsRel: docsRel })
1373
+ })
1374
+ })
1375
+ json(res, { ok: true, name: name, matches: matches, folders: regFolders.length })
1376
+ } catch (e) { error(res, 500, { ok: false, error: e.message }) }
1377
+ }}))
1378
+ // GET /api/canvas/load-doc?rel=canvas-docs/xxx.md —— 把 canvas-docs 里的 md
1379
+ // 注册为话布(按标题找已有,找不到新建)并返回画布 id——即 canvas_load 的最小实现
1380
+ ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/load-doc', handler: async (req, res) => {
1381
+ try {
1382
+ const url = new URL(req.url, 'http://x')
1383
+ 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 }
1385
+ const full = path.join(docsDir(), rel.slice('canvas-docs/'.length))
1386
+ if (!nodefs.existsSync(full)) { error(res, 404, { ok: false, error: '文件不存在' }); return }
1387
+ const content = nodefs.readFileSync(full, 'utf8')
1388
+ const title = rel.split('/').pop().replace(/\.(md|markdown)$/i, '')
1389
+ let c = null
1390
+ // 先按标题找;找不到再按「已绑定到同一个文件」找。
1391
+ // 只按标题去重是不够的:标题不同但指向同一文件时仍会新建一条,
1392
+ // 于是同一文件挂两条记录,旧的那条内容过期、保存即覆盖。
1393
+ let fullKey
1394
+ try { fullKey = path.resolve(full) } catch (_) { fullKey = full }
1395
+ canvases.forEach(function (x) {
1396
+ if (c || !x.filePath) return
1397
+ let k
1398
+ try { k = path.resolve(String(x.filePath)) } catch (_) { k = String(x.filePath) }
1399
+ if (k === fullKey) c = x
1400
+ })
1401
+ canvases.forEach(function (x) { if (!c && x.title === title) c = x })
1402
+ let created = false
1403
+ if (!c) {
1404
+ c = createCanvasDoc(title)
1405
+ applyWrite(c, content, 'replace', undefined, 'human')
1406
+ try { await saveCanvas(c) } catch (e) { console.error('[collab-canvas] load-doc save failed:', e && e.message) }
1407
+ created = true
1408
+ }
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 }) }
1412
+ }}))
1413
+ // ─── 白名单:本地文档「就地」登记为话布 ───────────────────────────
1414
+ // 允许范围 = 会话工作区 ∪ 画布目录 ∪ 文件登记表里登记的文件夹。
1415
+ // 路径先 resolve 归一化再比较,防止 ../ 逃逸;不满足即拒绝(不开全盘口子)。
1416
+ function pathAllowedInTarget(target) {
1417
+ let t
1418
+ try { t = path.resolve(String(target || '')) } catch (_) { return false }
1419
+ const roots = []
1420
+ try { roots.push(path.resolve(sessionWorkspaceDir())) } catch (_) {}
1421
+ try { roots.push(path.resolve(docsDirBase())) } catch (_) {}
1422
+ regFolders.forEach(function (f) { try { roots.push(path.resolve(f)) } catch (_) {} })
1423
+ for (let i = 0; i < roots.length; i++) {
1424
+ const rel = path.relative(roots[i], t)
1425
+ if (rel === '' || (rel.indexOf('..') !== 0 && !path.isAbsolute(rel))) return true
1426
+ }
1427
+ return false
1428
+ }
1429
+ // GET /api/canvas/load-path?path=<绝对路径|工作区相对路径>&sid=xxx
1430
+ // —— 把白名单内的本地文档就地登记为话布:**绑定原路径,保存即写回原文件**(不是副本)。
1431
+ // 与 load-doc 的区别:load-doc 只认 canvas-docs/ 下的文件、复制内容另存;
1432
+ // 这里支持工作区/登记文件夹内的任意 .md/.markdown/.txt,且保持就地编辑语义。
1433
+ ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/load-path', handler: async (req, res) => {
1434
+ try {
1435
+ const url = new URL(req.url, 'http://x')
1436
+ 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 }
1439
+ const abs = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(sessionWorkspaceDir(), raw)
1440
+ if (!pathAllowedInTarget(abs)) {
1441
+ error(res, 403, { ok: false, error: '路径不在允许范围内(会话工作区 / 画布目录 / 已登记文件夹)' })
1442
+ return
1443
+ }
1444
+ if (!nodefs.existsSync(abs)) { error(res, 404, { ok: false, error: '文件不存在' }); return }
1445
+ let c = null
1446
+ canvases.forEach(function (x) {
1447
+ if (!c && x.filePath) { try { if (path.resolve(x.filePath) === abs) c = x } catch (_) {} }
1448
+ })
1449
+ let created = false
1450
+ 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 }) }
1454
+ }}))
1455
+ // GET /api/canvas/delete?id=xxx&sid=xxx —— 从当前会话清单移除画布;
1456
+ // 若其他会话清单仍引用该画布则只摘引用保留文件,无人引用才连 md 一起删
1457
+ ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/delete', handler: async (req, res) => {
1458
+ try {
1459
+ const url = new URL(req.url, 'http://x')
1460
+ const id = url.searchParams.get('id')
1461
+ const sid = sessSafe(url.searchParams.get('sid'))
1462
+ const c = id ? canvases.get(id) : null
1463
+ if (!c) { error(res, 404, { ok: false, error: '画布不存在' }); return }
1464
+ sessRemove(sid, id)
1465
+ let referenced = false
1466
+ try {
1467
+ const files = nodefs.readdirSync(sessDirPath())
1468
+ for (const f of files) {
1469
+ if (!f.endsWith('.json') || f === sessSafe(sid) + '.json') continue
1470
+ try {
1471
+ const j = JSON.parse(nodefs.readFileSync(path.join(sessDirPath(), f), 'utf8'))
1472
+ if (Array.isArray(j.ids) && j.ids.indexOf(id) >= 0) { referenced = true; break }
1473
+ } catch (_) {}
1474
+ }
1475
+ } catch (_) {}
1476
+ if (!referenced) {
1477
+ canvases.delete(id)
1478
+ if (activeId === id) activeId = null
1479
+ try {
1480
+ if (c.filePath && nodefs.existsSync(c.filePath)) nodefs.unlinkSync(c.filePath)
1481
+ } catch (e) { console.error('[collab-canvas] delete file failed:', e && e.message) }
1482
+ persistMeta()
1483
+ }
1484
+ json(res, { ok: true, id: id, title: c.title, fileDeleted: !referenced })
1485
+ } catch (e) { error(res, 500, { ok: false, error: e.message }) }
1486
+ }}))
1487
+ // POST /api/canvas/import-doc?name=xxx —— 外部文档拖入/上传:复制到会话工作区
1488
+ // 的 uploads/ 文件夹(@ 文件选择器可索引到),返回相对路径供 @ 引用
1489
+ ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/import-doc', handler: async (req, res) => {
1490
+ try {
1491
+ if (req.method !== 'POST') { error(res, 405, { ok: false, error: 'POST only' }); return }
1492
+ const url = new URL(req.url, 'http://x')
1493
+ let name = url.searchParams.get('name') || 'doc.md'
1494
+ 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 }
1497
+ const dir = path.join(sessionWorkspaceDir(), 'uploads')
1498
+ try { nodefs.mkdirSync(dir, { recursive: true }) } catch (e) {}
1499
+ const fname = Date.now() + '-' + Math.random().toString(36).slice(2, 6) + '-' + name
1500
+ nodefs.writeFileSync(path.join(dir, fname), body)
1501
+ 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 }) }
1504
+ }}))
1505
+ // GET /api/canvas/open-upload?name=xxx —— 把拖入上传的文档变成/打开话布:
1506
+ // 已有同名(去掉时间戳前缀后的标题,含/不含 .md 扩展名)话布 → 直接返回其 id;
1507
+ // 否则新建话布并写入文件内容。只允许读 uploads/ 目录、文件名限本插件生成的格式
1508
+ ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/open-upload', handler: async (req, res) => {
1509
+ try {
1510
+ const url = new URL(req.url, 'http://x')
1511
+ 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 }
1513
+ const upDir = path.join(sessionWorkspaceDir(), 'uploads')
1514
+ 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 }
1517
+ const content = nodefs.readFileSync(full, 'utf8')
1518
+ const title = fname.replace(/^\d{13}-[a-z0-9]{2,8}-/i, '')
1519
+ const baseTitle = title.replace(/\.(md|markdown)$/i, '')
1520
+ // 查重按“路径”判断:拖拽拿不到源文件真实路径(安全限制),但拖进来
1521
+ // 的文件名就是路径的最后一段,而画布文档的存盘名 = 标题 + .md,
1522
+ // 因此“文件名(含/不含扩展名) = 画布标题”即视为同一篇文档,不按内容判断
1523
+ let c = null, matched = 'created'
1524
+ canvases.forEach(function (x) {
1525
+ if (!c && (x.title === title || x.title === baseTitle)) { c = x; matched = 'title' }
1526
+ })
1527
+ let created = false
1528
+ if (!c) {
1529
+ c = createCanvasDoc(title)
1530
+ applyWrite(c, content, 'replace', undefined, 'human')
1531
+ try { await saveCanvas(c) } catch (e) { console.error('[collab-canvas] open-upload save failed:', e && e.message) }
1532
+ created = true
1533
+ }
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 }) }
1537
+ }}))
1538
+ // 画布附件与独立模块源码服务(file / docdrop.js / doclink.js)
1539
+ function canvasDocDir() {
1540
+ const c = activeId ? canvases.get(activeId) : null
1541
+ const raw = (c && c.filePath) ? path.dirname(c.filePath) : docsDir()
1542
+ let dir = path.isAbsolute(raw) ? raw : path.join(docsDirBase(), raw)
1543
+ dir = dir.replace(/[\\/]+$/, '')
1544
+ if (process.platform === 'win32') dir = path.win32.normalize(dir.replace(/\//g, '\\'))
1545
+ return dir
1546
+ }
1547
+ const MIME_MAP = {
1548
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
1549
+ svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/x-icon',
1550
+ mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
1551
+ mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg',
1552
+ pdf: 'application/pdf', txt: 'text/plain; charset=utf-8', md: 'text/plain; charset=utf-8', json: 'application/json; charset=utf-8',
1553
+ }
1554
+ // GET /api/canvas/file?name=attachments/xxx.png —— 读画布附件(路径限制在画布目录内)
1555
+ ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/file', handler: async (req, res) => {
1556
+ try {
1557
+ const url = new URL(req.url, 'http://x')
1558
+ const rel = url.searchParams.get('name') || ''
1559
+ const base = path.resolve(canvasDocDir())
1560
+ const full = path.resolve(base, rel)
1561
+ const norm = (p) => (process.platform === 'win32' ? p.toLowerCase() : p)
1562
+ if (norm(full) !== norm(base) && norm(full).indexOf(norm(base) + path.sep) !== 0) {
1563
+ error(res, 403, { ok: false, error: '非法路径' }); return
1564
+ }
1565
+ if (!nodefs.existsSync(full) || !nodefs.statSync(full).isFile()) {
1566
+ error(res, 404, { ok: false, error: '文件不存在' }); return
1567
+ }
1568
+ const ext = (full.split('.').pop() || '').toLowerCase()
1569
+ const mime = MIME_MAP[ext] || 'application/octet-stream'
1570
+ const buf = nodefs.readFileSync(full)
1571
+ const disp = /^(image|video|audio)\//.test(mime) || ext === 'pdf' ? 'inline' : 'attachment'
1572
+ const fname = path.basename(full)
1573
+ res.writeHead(200, {
1574
+ 'Content-Type': mime,
1575
+ 'Content-Length': buf.length,
1576
+ 'Content-Disposition': disp + "; filename*=UTF-8''" + encodeURIComponent(fname),
1577
+ 'Cache-Control': 'no-cache',
1578
+ })
1579
+ res.end(buf)
1580
+ } catch (e) { error(res, 500, { ok: false, error: e.message }) }
1581
+ }}))
1582
+ // POST /api/canvas/upload?name=xxx —— 画布附件上传(图片粘贴等),存 canvas-docs/attachments/
1583
+ ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/upload', handler: async (req, res) => {
1584
+ try {
1585
+ if (req.method !== 'POST') { error(res, 405, { ok: false, error: 'POST only' }); return }
1586
+ const url = new URL(req.url, 'http://x')
1587
+ let name = url.searchParams.get('name') || 'file.bin'
1588
+ name = String(name).replace(/[\\/:*?"<>|\r\n]/g, '_').trim() || 'file.bin'
1589
+ const body = await readRawBody(req, UPLOAD_LIMIT)
1590
+ if (!body || !body.length) { error(res, 400, { ok: false, error: '空文件' }); return }
1591
+ const dir = path.join(canvasDocDir(), 'attachments')
1592
+ try { nodefs.mkdirSync(dir, { recursive: true }) } catch (e) {}
1593
+ const fname = Date.now() + '-' + name
1594
+ nodefs.writeFileSync(path.join(dir, fname), body)
1595
+ 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 }) }
1598
+ }}))
1599
+ function readRawBody(req, limit) {
1600
+ return new Promise((resolve, reject) => {
1601
+ const chunks = []
1602
+ let size = 0
1603
+ req.on('data', (c) => {
1604
+ size += c.length
1605
+ if (size > limit) { reject(new Error('文件超过大小上限')); try { req.destroy() } catch (_) {} ; return }
1606
+ chunks.push(c)
1607
+ })
1608
+ req.on('end', () => resolve(Buffer.concat(chunks)))
1609
+ req.on('error', reject)
1610
+ })
1611
+ }
1612
+ // GET /api/canvas/docdrop.js、doclink.js、selref.js —— 提供独立客户端模块源码
1613
+ //(client 入口动态挂载;每次读盘,改完刷新页面即生效,无需重启)。
1614
+ // 注意:运行时路径相对于部署目录的 dist/host.js 解析(../lib/…)
1615
+ // ⚠️ 这个名单是服务端常量,新增一项需要重启 dsh web 才认得出该端点。
1616
+ ;['docdrop.js', 'doclink.js', 'selref.js'].forEach(function (modFile) {
1617
+ ctx.effect(() => webServer.register({ kind: 'exact', path: '/api/canvas/' + modFile, handler: async (req, res) => {
1618
+ try {
1619
+ const srcPath = new URL('../lib/' + modFile, import.meta.url)
1620
+ const src = nodefs.readFileSync(srcPath, 'utf8')
1621
+ res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-cache' })
1622
+ res.end(src)
1623
+ } catch (e) { error(res, 500, { ok: false, error: e.message }) }
1624
+ }}))
1625
+ })
1626
+
1627
+ // 启动文件登记表:无配置文件则先写默认(会话工作区),再自愈补登记 + 异步扫描
1628
+ setTimeout(function () {
1629
+ try {
1630
+ if (!nodefs.existsSync(REG_CONFIG_FILE())) {
1631
+ regSaveConfig([sessionWorkspaceDir()])
1632
+ console.log('[collab-canvas] registry: 首次运行,默认登记会话工作区 ->', sessionWorkspaceDir())
1633
+ }
1634
+ regSeedIfEmpty()
1635
+ console.log('[collab-canvas] file registry:', regFolders.length ? regFolders.join(' | ') : '(未登记文件夹)', '索引', regTotal, '文件')
1636
+ } catch (e) { console.error('[collab-canvas] registry init failed:', e && e.message) }
1637
+ }, 0)
1638
+ }
1639
+
1640
+ // ==================== 13-selref-context.js ====================
1641
+ // ═══ 话布选区 · 把「用户引用的那段到底在哪」主动告诉模型 ═════════════════
1642
+ // 为什么要有它(2026-09-15 主人拍板):
1643
+ // canvas_locate 是「模型自己来问」。实测靠不住 —— 模型看到引用标签上的
1644
+ // 「多处(第 2 处)」很可能**不问**,凭感觉挑一处就改,改错了比不回答更糟。
1645
+ // 所以改成「先把答案送到它手上」:用户一旦把引用发出来,位置就已经在
1646
+ // 它的上下文里躺着了,它不需要知道有这么个工具也能改对地方。
1647
+ //
1648
+ // canvas_locate 不撤:注入可能因为各种原因没覆盖到(用户手打引用、格式怪、
1649
+ // 解析失败),那时它仍是兜底。两条路是**互补**的,不是替代。
1650
+ //
1651
+ // 位置怎么来的:不新算一套。复用 07-tools.js 里 canvas_locate 的 occIndexes /
1652
+ // lineColOf(同一作用域,直接可用),保证"注入说的"和"工具说的"永远一致。
1653
+ //
1654
+ // 生命周期:**完全交给官方**,自己一个定时器都不加。
1655
+ // 官方每个 step 都会重新求值下面这个 provider,只有文本变了才推一条新的
1656
+ // 上下文消息、文本变空就把上一条抹掉(RuntimeContextProjection 的替换机制)。
1657
+ // 于是:
1658
+ // · 用户这轮发了引用 → 算出内容 → 注入
1659
+ // · 同一轮里模型跑很多步 → 文本没变 → 不重复刷屏
1660
+ // · 下一轮用户没引用 → 文本变空 → 官方自动抹掉,不留残影
1661
+ // ⚠️ 所以**千万别加 TTL / 别自己清** —— 那不是帮忙,是跟官方机制打架。
1662
+ //
1663
+ // ⚠️ 这条消息会以一行「注入」的样子出现在聊天里(跟时间上下文一模一样,
1664
+ // label 是插件名)。不是隐形注入,主人看得见。
1665
+ //
1666
+ // [[CCV-SELCTX-BEGIN]] —— 下面到 END 之间是这块功能的全部代码。
1667
+ // tools/test-host-selctx.cjs 靠这两个标记把这段**真代码**切出来单独跑,
1668
+ // 不是照抄一份来测。挪动或删除标记会让那份测试失效。
1669
+
1670
+ // ── 一、取「用户这一步要处理的那条消息」─────────────────────────
1671
+ // 官方求值 provider 时会把当前 agent 交给我们(assembleContextFor 里
1672
+ // 返回 {agent, scope, signal}),顺着 agent.session 就能读到这次对话。
1673
+ // ⚠️ 必须**同步**:官方求值不带 await(`entry.text(context)` 直接调用),
1674
+ // 返回 Promise 只会被当成一个奇怪的字符串塞进 prompt。
1675
+ //
1676
+ // 🔴🔴 为什么要**两条通道**(2026-09-15 主人报「提示讲的是上一条消息」)
1677
+ //
1678
+ // 官方第 1 步求值上下文的顺序是死的(dsh-agent-loop 的 preStep):
1679
+ // ① claim() 把待处理消息从队列里取走
1680
+ // ② systemPrompt.assemble() ← 我们在这里求值
1681
+ // ③ session.append("user/message", …) 消息**这时**才写进会话界面
1682
+ // 所以第 1 步里"会话界面上的最后一条人消息"**就是上一条**,怎么看都晚一拍
1683
+ // (一天四次实测都是这个规律,同一回合的第 2 步才追上)。
1684
+ //
1685
+ // → **不能只看界面。** 还有一条更早的记录:官方**一收到**消息就把它放进
1686
+ // 待处理队列(`agent/inbox/spliced` 事件里的 data.inserted),那一步在整轮
1687
+ // 开始之前,所以第 1 步也读得到 —— 它才是"这一步正在处理的那条消息"。
1688
+ //
1689
+ // 两条通道都读,取**新的那条**:
1690
+ // · 队列那条更新 → 界面还没追上,用队列的(第 1 步就靠它)
1691
+ // · 界面那条更新 → 界面已追上,两条内容一样,用界面那条更省事
1692
+ // 兜底通道(只能看界面时)再加一道**否决**,见 ccvTurnMsg。
1693
+
1694
+ // ── 诊断:服务端这一侧怎么判断的,也落盘 ─────────────────────────
1695
+ // 客户端那份日志在 %TEMP%/ccv-selbar.log(09-http.js 落盘)。
1696
+ // 这里另写一份 %TEMP%/ccv-selctx.log —— **不抢同一个文件**,复盘时两边对得上。
1697
+ // 「为什么没注入」和「注入了什么」都写在这条线上;全部 try/catch,
1698
+ // 诊断绝不能影响注入本身。
1699
+ var CCV_TRACE_FILE = ''
1700
+ try {
1701
+ if (typeof process !== 'undefined' && process && process.env) {
1702
+ CCV_TRACE_FILE = (process.env.TEMP || process.env.TMPDIR || '.') + '/ccv-selctx.log'
1703
+ }
1704
+ } catch (e) { CCV_TRACE_FILE = '' }
1705
+ var ccvTraceRing = []
1706
+
1707
+ function ccvClock(t) {
1708
+ try {
1709
+ var d = new Date(t)
1710
+ function p(n) { return n < 10 ? '0' + n : '' + n }
1711
+ return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds())
1712
+ } catch (e) { return String(t) }
1713
+ }
1714
+
1715
+ function ccvTrace(msg) {
1716
+ try {
1717
+ ccvTraceRing.push(ccvClock(Date.now()) + ' ' + String(msg))
1718
+ if (ccvTraceRing.length > 300) ccvTraceRing = ccvTraceRing.slice(ccvTraceRing.length - 300)
1719
+ if (!CCV_TRACE_FILE) return
1720
+ if (typeof nodefs === 'undefined' || !nodefs || !nodefs.writeFileSync) return
1721
+ nodefs.writeFileSync(CCV_TRACE_FILE, ccvTraceRing.join('\n') + '\n', 'utf8')
1722
+ } catch (e) { /* 落盘失败不影响注入 */ }
1723
+ }
1724
+
1725
+ // 「不是人打的字」的来源。这些一律跳过,继续往前找。
1726
+ var CCV_INJECT_KINDS = {
1727
+ 'plugin': true, // 插件注入的上下文(包括我们自己这条)
1728
+ 'session-reference': true, // 跨会话引用
1729
+ 'agent-instructions': true,
1730
+ 'skill-invocation': true
1731
+ }
1732
+
1733
+ // 一条消息里的纯文字。content 可能是字符串,也可能是分块的数组。
1734
+ function ccvMsgText(msg) {
1735
+ if (!msg) return ''
1736
+ var content = msg.content
1737
+ if (typeof content === 'string') return content
1738
+ if (!content || !content.length) return ''
1739
+ var out = []
1740
+ for (var i = 0; i < content.length; i++) {
1741
+ var b = content[i]
1742
+ if (b && b.type === 'text' && typeof b.text === 'string') out.push(b.text)
1743
+ }
1744
+ return out.join('\n')
1745
+ }
1746
+
1747
+ // 这条消息算不算"人打的"。是 → 返回正文;不是 → null。
1748
+ function ccvHumanTextOf(msg) {
1749
+ if (!msg) return null
1750
+ var kind = msg.source && msg.source.kind
1751
+ if (kind && CCV_INJECT_KINDS[kind]) return null
1752
+ var t = ccvMsgText(msg)
1753
+ return t || null
1754
+ }
1755
+
1756
+ // ── 通道 A:会话界面(可能晚一拍)────────────────────────────────
1757
+ // ⚠️ 必须**往前找**而不是只看最后一条:我们自己注入的那条也是 user/message,
1758
+ // 只看最后一条会看到自己,那就永远解析不出引用了。
1759
+ // 反过来这也给了我们一个好性质:用户发了新的、不带引用的消息时,
1760
+ // 最近这条就变成它 → 解析不出引用 → 返回空 → 官方把注入抹掉。
1761
+ function ccvFromSurface(agent) {
1762
+ var session = agent && agent.session
1763
+ if (!session) return null
1764
+ var nodes = session.surface && session.surface.nodes
1765
+ if (!nodes || !nodes.length) return null
1766
+ for (var i = nodes.length - 1; i >= 0; i--) {
1767
+ var ev = session.eventAt(nodes[i])
1768
+ if (!ev || ev.type !== 'user/message') continue
1769
+ var t = ccvHumanTextOf(ev.data)
1770
+ if (!t) continue
1771
+ return { text: t, time: typeof ev.time === 'number' ? ev.time : 0, src: '界面' }
1772
+ }
1773
+ return null
1774
+ }
1775
+
1776
+ // ── 通道 B:待处理队列的记录(更早,不晚拍)───────────────────────
1777
+ // 事件名与形状照抄官方(dsh-agent-loop 的 ReactLoopInbox.mutate):
1778
+ // session.append('agent/inbox/spliced', { target, start, inserted, … })
1779
+ // 认领/取消只带 removedCount、inserted 为空 → 跳过(那是噪音,不是新消息)。
1780
+ //
1781
+ // 🔴 这里用**白名单**:只有 source.kind === 'user' 才算人打的。
1782
+ // 理由:这条队列里还流着别的东西(工具结果带回来的补充上下文等),
1783
+ // 用"排除法"容易漏掉没见过的 kind,那就把机器塞的东西当成用户说的话了。
1784
+ // (界面那条通道仍用排除法 —— 它本来就工作正常,不动它。)
1785
+ var CCV_INBOX_EVENT = 'agent/inbox/spliced'
1786
+
1787
+ function ccvInboxHumanText(msg) {
1788
+ var kind = msg && msg.source && msg.source.kind
1789
+ if (kind !== 'user') return null
1790
+ var t = ccvMsgText(msg)
1791
+ return t || null
1792
+ }
1793
+
1794
+ function ccvFromInbox(agent) {
1795
+ var session = agent && agent.session
1796
+ if (!session || typeof session.snapshotEvents !== 'function') return null
1797
+ var events = null
1798
+ try { events = session.snapshotEvents() } catch (e) { return null }
1799
+ if (!events || !events.length) return null
1800
+ for (var i = events.length - 1; i >= 0; i--) {
1801
+ var ev = events[i]
1802
+ if (!ev || ev.type !== CCV_INBOX_EVENT) continue
1803
+ var ins = ev.data && ev.data.inserted
1804
+ if (!ins || !ins.length) continue
1805
+ // 这一批里没有"人打的字"(多半是工具结果带进来的补充上下文)→ 继续往前翻。
1806
+ // 往前翻是安全的:翻出来的**一定不比界面那条更旧**(提交必然早于进界面),
1807
+ // 所以不会退化成"讲上一条消息"。
1808
+ for (var k = ins.length - 1; k >= 0; k--) {
1809
+ var t = ccvInboxHumanText(ins[k])
1810
+ if (t) return { text: t, time: typeof ev.time === 'number' ? ev.time : 0, src: '队列' }
1811
+ }
1812
+ }
1813
+ return null
1814
+ }
1815
+
1816
+ // 挑出「这一步要处理的那条消息」→ {text, time, src};拿不到 → null。
1817
+ // 🔴 兜底通道(只能看界面)上多一道**否决**:界面这条消息,比我收到的最新一次
1818
+ // 选区上报还旧 → 说明用户在这条之后又引用过东西、界面还没追上 → 我看的不是
1819
+ // 这一步的消息 → **一个字都不说**。宁可不说,也不说错。
1820
+ // (队列通道不需要这道:队列那条就是提交本身,不可能比上报旧。)
1821
+ function ccvTurnMsg(agent) {
1822
+ var byInbox = ccvFromInbox(agent)
1823
+ var bySurface = ccvFromSurface(agent)
1824
+ if (byInbox && bySurface) return byInbox.time >= bySurface.time ? byInbox : bySurface
1825
+ if (byInbox) return byInbox
1826
+ if (!bySurface) return null
1827
+ var newestPinAt = ccvPins.length ? (ccvPins[ccvPins.length - 1].at || 0) : 0
1828
+ if (bySurface.time > 0 && newestPinAt > bySurface.time) {
1829
+ ccvTrace('否决:界面这条消息(' + ccvClock(bySurface.time) + ')比我收到的最新上报('
1830
+ + ccvClock(newestPinAt) + ')还旧 → 界面没追上,本轮不注入')
1831
+ return null
1832
+ }
1833
+ return bySurface
1834
+ }
1835
+
1836
+ // ── 一·五、客户端报上来的「你划的是第几处」──────────────────────
1837
+ // 为什么服务端自己算不出:序数靠的是**划选那一刻的 DOM 位置**,
1838
+ // 只有浏览器那边知道 —— 服务端只知道原文长什么样,不知道你指的是哪一处。
1839
+ //
1840
+ // 🔴 而引用正文里现在**也不写坐标了**(2026-09-15 主人要求卡片只留文件名),
1841
+ // 所以这条上报通道成了"第几处"的**唯一来源**。它和 lib/selref.js 里的
1842
+ // pinUp() 是一对,**必须一起改**:只改一边,模型就彻底不知道用户指哪儿了。
1843
+ //
1844
+ // 上报时机是 capture()(用户点「问AI」/划选那一刻),比发送早得多,
1845
+ // 所以这里不会出现"消息先到、记录后到"的竞态。
1846
+ var CCV_PINS_MAX = 8 // 只留最近几条,覆盖式;不清理也不会涨
1847
+ var ccvPins = []
1848
+ // 每收一条上报就 +1。**专供下面那份 memo 失效用**,见 ccvBuildSelContext。
1849
+ // 没有它会出一个很难发现又很致命的错:用户前后两次引用同一段原文
1850
+ // (这段文字在文档里出现多处),两次的引用正文**一字不差**,
1851
+ // memo 会把第二次挡掉、继续用第一次的序数 → 模型改错地方。
1852
+ var ccvPinSeq = 0
1853
+
1854
+ function ccvPinAdd(p) {
1855
+ try {
1856
+ if (!p || typeof p !== 'object') return false
1857
+ var text = String(p.text || '')
1858
+ if (!text) return false
1859
+ ccvPins.push({
1860
+ code: p.code,
1861
+ path: ccvNormPathForRef(p.path),
1862
+ title: String(p.title || ''),
1863
+ text: text,
1864
+ ordinal: typeof p.ordinal === 'number' ? p.ordinal : 0,
1865
+ dupCount: typeof p.dupCount === 'number' ? p.dupCount : 0,
1866
+ line: typeof p.line === 'number' ? p.line : -1,
1867
+ heading: String(p.heading || ''),
1868
+ // 时间戳默认取"此刻";允许上报里自带(诊断与测试要造"更旧/更新"的场景)。
1869
+ at: typeof p.at === 'number' && p.at > 0 ? p.at : Date.now(),
1870
+ })
1871
+ if (ccvPins.length > CCV_PINS_MAX) ccvPins = ccvPins.slice(ccvPins.length - CCV_PINS_MAX)
1872
+ ccvPinSeq++
1873
+ return true
1874
+ } catch (e) {
1875
+ return false
1876
+ }
1877
+ }
1878
+
1879
+ // 找出这条引用对应的上报记录。取**最新**的一条 —— 用户的操作顺序天然是
1880
+ // 「先划选、再发送」,所以最新那条就是这次的。
1881
+ // ⚠️ 已知边界:同一段原文连着划两次、却只发出第一条引用时,会取到后一条的序数。
1882
+ // 罕见,且注入文案里保留了每处的上下文供核对,不会静默改错。
1883
+ //
1884
+ // 🔴 `used` 是**本次拼装里已经用掉的上报**:一条消息里引用了两段不同的原文、
1885
+ // 但它们被解析成同一个 `q`(比如原文本身含「」被截短)时,两条会抢同一条上报
1886
+ // → 第二条拿到第一条的序数 → 改错地方。所以用过的不能再给下一条用。
1887
+ function ccvPinFind(path, quote, used) {
1888
+ var want = ccvNormPathForRef(path)
1889
+ var q = String(quote || '')
1890
+ if (!q) return null
1891
+ for (var i = ccvPins.length - 1; i >= 0; i--) {
1892
+ var p = ccvPins[i]
1893
+ if (used && used.length && used.indexOf(p) >= 0) continue
1894
+ // 精确相等最好;但解析出来的原文在"原文本身含「」"时会被截短,
1895
+ // 所以也接受"它是上报原文的前缀"(截短只会往前截,不会变形)。
1896
+ if (p.text !== q && p.text.indexOf(q) !== 0) continue
1897
+ if (want && p.path && p.path !== want) continue
1898
+ return p
1899
+ }
1900
+ return null
1901
+ }
1902
+
1903
+ // ── 二、从消息正文里认出「话布选区引用」──────────────────────────
1904
+ // 引用正文的形状由 lib/selref.js 的 codec.serialize 生成,**只有两行**:
1905
+ // @"D:\...\canvas-docs\话布优化.md"
1906
+ // 「原文」
1907
+ // 拿不到真实路径时第 1 行退化成:
1908
+ // 【话布选区】文档名
1909
+ // 「原文」
1910
+ //
1911
+ // 🔴 2026-09-15 起标签**整串没有了**(主人要求对话里那张卡片只留文件名)。
1912
+ // 于是"第几处"改从上报记录拿(ccvPinFind)。但抬头仍然要认、也仍然要
1913
+ // **兼容带标签的老形态** —— 浏览器缓存里的旧客户端、以及用户手打的引用,
1914
+ // 都可能还带着「> 小标题 · 第 21 行 · 多处(第 2 处)」。
1915
+ //
1916
+ // 只有上面这两种抬头才认。**用户自己在正文里写**
1917
+ // 他说:
1918
+ // 「你好」
1919
+ // **绝不能被当成引用** —— 那会让我们凭空定位一段用户根本没引用的话。
1920
+ var CCV_REF_LINE_RE = /(^|\n)([^\n]*)\n「([\s\S]*?)」/g
1921
+
1922
+ // 抬头是不是引用。返回 'mention' / 'plain' / null(null = 不认)。
1923
+ function ccvRefHeadKind(head) {
1924
+ var h = String(head || '').trim()
1925
+ if (!h) return null
1926
+ if (h.charAt(0) === '@') return 'mention'
1927
+ if (h.indexOf('【话布选区】') === 0) return 'plain'
1928
+ return null
1929
+ }
1930
+
1931
+ // 从 @"路径" 里取出路径。没写引号就截到 " > " 之前。
1932
+ // ⚠️ 先 trim:抬头行前面可能带空格(用户手工复制粘贴引用时会),
1933
+ // 不 trim 就会认不出路径 → 安静地退化成"没找到文档"。实测踩过。
1934
+ function ccvRefPath(head) {
1935
+ var h = String(head || '').trim()
1936
+ if (h.charAt(0) !== '@') return ''
1937
+ var rest = h.slice(1)
1938
+ if (rest.charAt(0) === '"') {
1939
+ var end = rest.indexOf('"', 1)
1940
+ if (end > 0) return rest.slice(1, end)
1941
+ }
1942
+ var gt = rest.indexOf(' > ')
1943
+ return (gt >= 0 ? rest.slice(0, gt) : rest).trim()
1944
+ }
1945
+
1946
+ // 从 【话布选区】文档名 · 标签 里取文档名。(同样要先 trim)
1947
+ function ccvRefTitle(head) {
1948
+ var h = String(head || '').trim()
1949
+ var tag = '【话布选区】'
1950
+ if (h.indexOf(tag) !== 0) return ''
1951
+ var rest = h.slice(tag.length)
1952
+ var cut = rest.indexOf(' · ')
1953
+ return (cut >= 0 ? rest.slice(0, cut) : rest).trim()
1954
+ }
1955
+
1956
+ // 标签里的「多处(第 2 处)」→ 2。没写「第几处」→ 0(不编数字)。
1957
+ function ccvRefOrdinal(head) {
1958
+ var m = /多处(第\s*(\d+)\s*处)/.exec(String(head || ''))
1959
+ if (!m) return 0
1960
+ var n = parseInt(m[1], 10)
1961
+ return isFinite(n) && n > 0 ? n : 0
1962
+ }
1963
+
1964
+ // 「多处」两个字在不在。标记存在但序数没对上时,仍然要告诉模型别猜。
1965
+ function ccvRefHasDup(head) {
1966
+ return /多处/.test(String(head || ''))
1967
+ }
1968
+
1969
+ // 把一条消息里所有引用块抠出来 → [{head, kind, quote, ordinal, dup}]
1970
+ // ⚠️ 原文本身可能含「」:非贪婪先截到第一个「」,交给下面 ccvQuoteVariants
1971
+ // 在文档里找不到时再往下一个「」延长。这里只负责"先给最短的那个"。
1972
+ function ccvParseRefs(text) {
1973
+ var src = String(text || '')
1974
+ if (src.indexOf('「') < 0) return []
1975
+ var out = []
1976
+ CCV_REF_LINE_RE.lastIndex = 0
1977
+ var m
1978
+ while ((m = CCV_REF_LINE_RE.exec(src)) !== null) {
1979
+ var head = m[2]
1980
+ var kind = ccvRefHeadKind(head)
1981
+ if (!kind) continue // 抬头不像引用 → 用户自己写的,跳过
1982
+ var quote = m[3]
1983
+ if (!quote || !quote.replace(/\s/g, '')) continue
1984
+ out.push({
1985
+ head: head,
1986
+ kind: kind,
1987
+ quote: quote,
1988
+ path: kind === 'mention' ? ccvRefPath(head) : '',
1989
+ title: kind === 'plain' ? ccvRefTitle(head) : '',
1990
+ ordinal: ccvRefOrdinal(head),
1991
+ dup: ccvRefHasDup(head)
1992
+ })
1993
+ if (out.length >= 5) break // 一条消息里引用太多就不再认了
1994
+ }
1995
+ return out
1996
+ }
1997
+
1998
+ // 原文含「」时,上面会截短。给出"截短版 + 逐个延长版",让定位去试。
1999
+ // 正常情况下第一个就命中,延长版根本不看。
2000
+ function ccvQuoteVariants(text, start) {
2001
+ var out = []
2002
+ var from = start
2003
+ for (var i = 0; i < 3; i++) {
2004
+ var end = String(text).indexOf('」', from)
2005
+ if (end < 0) break
2006
+ out.push(String(text).slice(start, end))
2007
+ from = end + 1
2008
+ }
2009
+ return out.length ? out : [String(text).slice(start)]
2010
+ }
2011
+
2012
+ // ── 三、找文档 ──────────────────────────────────────────────────
2013
+ // ⚠️ 刻意**不用** findCanvasByArg:它在路径匹配不上时会退回「当前活跃文档」,
2014
+ // 而那很可能**不是**引用说的那份 —— 拿错文档算出来的位置比不注入更糟。
2015
+ // 这里宁可认不出(说"没找到"),也不猜。
2016
+ function ccvNormPathForRef(s) {
2017
+ return String(s || '').replace(/\\/g, '/').replace(/^"+|"+$/g, '').replace(/\/+$/, '')
2018
+ }
2019
+
2020
+ function ccvFindDoc(ref) {
2021
+ if (ref.path) {
2022
+ var want = ccvNormPathForRef(ref.path)
2023
+ if (!want) return null
2024
+ var hit = null
2025
+ canvases.forEach(function (c) {
2026
+ if (!hit && c.filePath && ccvNormPathForRef(c.filePath) === want) hit = c
2027
+ })
2028
+ if (!hit) {
2029
+ // 路径对不上时,用文件名兜一次(大小写与分隔符差异)
2030
+ var tail = want.slice(want.lastIndexOf('/') + 1)
2031
+ canvases.forEach(function (c) {
2032
+ if (hit || !c.filePath) return
2033
+ var cw = ccvNormPathForRef(c.filePath)
2034
+ if (cw.slice(cw.lastIndexOf('/') + 1) === tail) hit = c
2035
+ })
2036
+ }
2037
+ return hit
2038
+ }
2039
+ if (ref.title) {
2040
+ var byTitle = null
2041
+ canvases.forEach(function (c) { if (!byTitle && c.title === ref.title) byTitle = c })
2042
+ return byTitle
2043
+ }
2044
+ return null
2045
+ }
2046
+
2047
+ // ── 四、算定位,拼成给模型看的一段话 ─────────────────────────────
2048
+ var CCV_SEL_CTX_PAD = 24 // 每处前后各带多少字
2049
+ var CCV_SEL_MAX_SPOTS = 5 // 最多列几处
2050
+ var CCV_SEL_QUOTE_MAX = 40 // 摘要里原文最多显示几个字
2051
+ var CCV_SEL_TOTAL_MAX = 1200 // 整段注入的长度上限
2052
+
2053
+ function ccvShortQuote(s, max) {
2054
+ var t = String(s || '')
2055
+ return t.length <= max ? t : t.slice(0, max) + '…'
2056
+ }
2057
+
2058
+ // 一处长这样:…前文【原文】后文…
2059
+ function ccvSpotExcerpt(content, idx, quote) {
2060
+ var a = Math.max(0, idx - CCV_SEL_CTX_PAD)
2061
+ var b = Math.min(content.length, idx + quote.length + CCV_SEL_CTX_PAD)
2062
+ return (a > 0 ? '…' : '')
2063
+ + asOneLine(content.slice(a, idx))
2064
+ + '【' + asOneLine(ccvShortQuote(quote, CCV_SEL_QUOTE_MAX)) + '】'
2065
+ + asOneLine(content.slice(idx + quote.length, b))
2066
+ + (b < content.length ? '…' : '')
2067
+ }
2068
+
2069
+ // 单条引用 → 一段话。返回 '' 表示这条不值得注入(比如找不到文档)。
2070
+ // `used` 见 ccvPinFind:同一次拼装里上报记录不许被两条引用重复认领。
2071
+ function ccvDescribeRef(ref, used) {
2072
+ var who = ref.path || ref.title || '(未标注)'
2073
+ var c = ccvFindDoc(ref)
2074
+ if (!c) {
2075
+ return '· 用户引用了「' + ccvShortQuote(ref.quote, CCV_SEL_QUOTE_MAX)
2076
+ + '」(' + who + '),但没找到这份话布文档(可能已改名或删掉)。'
2077
+ + '先 canvas_list 看看,别在别的文档里找相似内容改。'
2078
+ }
2079
+ var content = String(c.content || '')
2080
+
2081
+ // 原文含「」时会被截短 —— 逐个延长版去试,谁在文档里出现就用谁
2082
+ var quote = ''
2083
+ var idxs = []
2084
+ var variants = ccvQuoteVariants(ref.quote, 0)
2085
+ for (var i = 0; i < variants.length; i++) {
2086
+ var tryQ = variants[i]
2087
+ if (!tryQ) continue
2088
+ var hits = occIndexes(content, tryQ)
2089
+ if (hits.length) { quote = tryQ; idxs = hits; break }
2090
+ }
2091
+
2092
+ // 全文都找不到 → 引用已经失效。这是最要紧的一种,必须说死。
2093
+ if (!idxs.length) {
2094
+ return '· 用户引用了《' + c.title + '》里的「' + ccvShortQuote(ref.quote, CCV_SEL_QUOTE_MAX)
2095
+ + '」,但这段原文**现在全文都找不到了** —— 这段引用已经失效'
2096
+ + '(多半是引用发出之后文档被改过)。'
2097
+ + '动手前请让用户重新划一次要引用的段落,**不要**去找相似的内容照着改。'
2098
+ }
2099
+
2100
+ var shown = idxs.slice(0, CCV_SEL_MAX_SPOTS)
2101
+
2102
+ // 只有一处:直接说死
2103
+ if (idxs.length === 1) {
2104
+ var lc1 = lineColOf(content, idxs[0])
2105
+ return '· 《' + c.title + '》「' + ccvShortQuote(quote, CCV_SEL_QUOTE_MAX)
2106
+ + '」在第 ' + lc1.line + ' 行,**全文只有这一处**:\n '
2107
+ + ccvSpotExcerpt(content, idxs[0], quote)
2108
+ }
2109
+
2110
+ // 多处:把目标那一处标出来,其余只报行号(够模型核对,又不啰嗦)
2111
+ //
2112
+ // 序数从哪来:**客户端刚报上来的**优先(引用正文里已经不写了);
2113
+ // 拿不到才退回看抬头标签(兼容没更新的客户端 / 用户手打的引用)。
2114
+ // 两个都没有 → 绝不替模型挑一处,让它把每处摆给用户确认。
2115
+ var pin = ccvPinFind(ref.path, quote, used)
2116
+ if (pin && used && used.indexOf(pin) < 0) used.push(pin)
2117
+ var ordinal = (pin && pin.ordinal > 0) ? pin.ordinal : ref.ordinal
2118
+
2119
+ var head = '· 《' + c.title + '》「' + ccvShortQuote(quote, CCV_SEL_QUOTE_MAX)
2120
+ + '」全文共 ' + idxs.length + ' 处'
2121
+ if (ordinal > 0 && ordinal <= idxs.length) {
2122
+ head += ',**用户要的就是第 ' + ordinal + ' 处**(他划中的是这一处)'
2123
+ } else {
2124
+ head += ',仅凭行号分不出来'
2125
+ }
2126
+ head += ':'
2127
+
2128
+ var lines = [head]
2129
+ var target = ordinal > 0 && ordinal <= idxs.length ? ordinal - 1 : -1
2130
+ for (var k = 0; k < shown.length; k++) {
2131
+ var lc = lineColOf(content, shown[k])
2132
+ var isTarget = k === target
2133
+ lines.push(' ' + (isTarget ? '★第 ' + (k + 1) + ' 处(第 ' + lc.line + ' 行)'
2134
+ : ' 第 ' + (k + 1) + ' 处(第 ' + lc.line + ' 行)')
2135
+ + ':' + ccvSpotExcerpt(content, shown[k], quote))
2136
+ }
2137
+ if (idxs.length > shown.length) {
2138
+ lines.push(' (后面还有 ' + (idxs.length - shown.length) + ' 处,略)')
2139
+ }
2140
+
2141
+ if (target >= 0) {
2142
+ lines.push(' → 动这段文字时照上面标 ★ 的那一处改,别的几处不要碰。')
2143
+ } else {
2144
+ lines.push(' → 分不出用户要哪一处。请把上面每处的上下文摆给用户,让他确认改哪一处,不要自己挑一处改。')
2145
+ }
2146
+ return lines.join('\n')
2147
+ }
2148
+
2149
+ // 把最近一条人消息里的引用整体描述出来。没有引用 / 出错 → 空串(= 不注入)。
2150
+ // ⚠️ 这个函数会被官方**每个 step 调一次**,而且**同步**调。所以:
2151
+ // · 全程 try/catch,任何异常都退化成一件事:不注入。
2152
+ // (provider 抛错会毁掉整个 prompt 组装 —— 那比不注入严重得多。)
2153
+ // · 结果按正文做一次 memo:同一轮里正文没变就直接复用。
2154
+ // ⚠️ 键里**必须**带上上报计数和记录条数(见下),不能只看正文 ——
2155
+ // 同一段原文出现多处时,用户前后两次引用拼出来的正文可以一字不差,
2156
+ // 只按正文做键就会把第二次挡掉,让模型拿着第一次的序数去改。
2157
+ var ccvSelCtxMemo = { key: null, val: '' }
2158
+
2159
+ function ccvBuildSelContext(context) {
2160
+ try {
2161
+ var agent = context && context.agent
2162
+ var picked = ccvTurnMsg(agent)
2163
+ if (!picked) { ccvSelCtxMemo = { key: null, val: '' }; return '' }
2164
+ var text = picked.text
2165
+ // ⚠️ 键里**必须**带上报计数与记录条数(见下),也带"这是哪条消息"(来源+时间)——
2166
+ // 少一个维度就会把"消息换了、内容恰好一样"的两次当成同一次,拿旧序数去改。
2167
+ var memoKey = picked.src + '#' + picked.time + '#' + text + '#' + ccvPinSeq + '#' + ccvPins.length
2168
+ if (ccvSelCtxMemo.key === memoKey) return ccvSelCtxMemo.val
2169
+
2170
+ var refs = ccvParseRefs(text)
2171
+ var blocks = []
2172
+ var usedPins = []
2173
+ for (var i = 0; i < refs.length && i < 3; i++) {
2174
+ var one = ccvDescribeRef(refs[i], usedPins)
2175
+ if (one) blocks.push(one)
2176
+ }
2177
+ var out = ''
2178
+ if (blocks.length) {
2179
+ out = '【话布选区】用户这轮引用了话布里的文字。它**到底在哪一处**已经先替你查好了,'
2180
+ + '按下面的位置改就行,不必再问:\n' + blocks.join('\n')
2181
+ if (out.length > CCV_SEL_TOTAL_MAX) out = out.slice(0, CCV_SEL_TOTAL_MAX) + '\n(内容过长,已截断)'
2182
+ }
2183
+ ccvTrace('这一步的消息来自「' + picked.src + '」(' + ccvClock(picked.time) + '),'
2184
+ + '认出 ' + refs.length + ' 条引用,注入 ' + blocks.length + ' 条;'
2185
+ + '手上上报 ' + ccvPins.length + ' 条')
2186
+ ccvSelCtxMemo = { key: memoKey, val: out }
2187
+ return out
2188
+ } catch (e) {
2189
+ try { console.error('[collab-canvas] sel-context failed:', e && e.message) } catch (_) {}
2190
+ return ''
2191
+ }
2192
+ }
2193
+
2194
+ // ── 五、注册成官方动态上下文 ────────────────────────────────────
2195
+ // name 必须全局唯一(重名官方直接抛错);order 只要是个有限数,
2196
+ // 排在其他上下文(沙箱策略 110 / 审批 115 / 子代理 120)之后。
2197
+ try {
2198
+ var spSel = ctx.get('systemPrompt')
2199
+ if (spSel && typeof spSel.context === 'function') {
2200
+ spSel.context({
2201
+ name: 'collab-canvas-selection',
2202
+ order: 160,
2203
+ text: ccvBuildSelContext,
2204
+ })
2205
+ console.log('[collab-canvas]', 'selection context registered')
2206
+ }
2207
+ } catch (e) {
2208
+ console.error('[collab-canvas] selection context failed:', e && e.message)
2209
+ }
2210
+ // [[CCV-SELCTX-END]]
2211
+
2212
+ // ==================== 99-tail.js ====================
2213
+ // 启动引导:恢复持久化状态 + 注册 HTTP 端点
2214
+ ;(async function () {
2215
+ await restore()
2216
+ console.log('[collab-canvas]', 'host half ready, docsDir =', docsDir())
2217
+
2218
+ // 注册画布 HTTP 端点
2219
+ try {
2220
+ const webServer = ctx.get('webServer')
2221
+ if (webServer) {
2222
+ initCanvasHttpEndpoints(ctx, webServer)
2223
+ initCanvasExtraEndpoints(ctx, webServer) // 11-canvas-extras.js:拖拽/登记表/删除/模块服务
2224
+ } else {
2225
+ console.warn('[collab-canvas]', 'webServer 服务未找到,跳过')
2226
+ }
2227
+ } catch (e) {
2228
+ console.error('[collab-canvas]', '初始化失败:', e && e.message)
2229
+ }
2230
+ })()
2231
+ }