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/LICENSE +21 -0
- package/README.md +129 -0
- package/cordis.patch.yml +4 -0
- package/dist/host.js +2231 -0
- package/lib/client.js +2541 -0
- package/lib/docdrop.js +362 -0
- package/lib/doclink.js +436 -0
- package/lib/selref.js +1162 -0
- package/package.json +60 -0
package/lib/selref.js
ADDED
|
@@ -0,0 +1,1162 @@
|
|
|
1
|
+
// ═══ DSH Collab-Canvas · 选区引用(独立功能模块,单独一个文件维护)═══
|
|
2
|
+
//
|
|
3
|
+
// 目标:把话布里选中的一段话,变成 DSH 输入框里一个「引用胶囊」——
|
|
4
|
+
// 可点、整块删除、按发送时自动翻译成带定位信息的上下文。
|
|
5
|
+
// 设计稿:canvas-docs/话布选区引用方案.md(v3)
|
|
6
|
+
//
|
|
7
|
+
// 加载方式:与 docdrop / doclink 完全相同 —— host 端点 /api/canvas/selref.js
|
|
8
|
+
// 提供源码,client 入口动态执行。**改本文件只需刷新页面,不用重启。**
|
|
9
|
+
//
|
|
10
|
+
// ── 与主模块的通信(重要)────────────────────────────────────────────
|
|
11
|
+
// 侧模块由 new Function(src)() 执行,作用域是**全局**,拿不到主模块的局部变量
|
|
12
|
+
// (React / h / ccvLog 等全都不可见)。所以两边只靠 window 上的约定握手:
|
|
13
|
+
//
|
|
14
|
+
// window.__ccvKit 主模块提供的共用工具(log / toast),取不到就用自带副本
|
|
15
|
+
// window.__ccvCtx 主模块 apply(ctx) 时挂上的 cordis 根上下文
|
|
16
|
+
// window.__ccvSelRefBridge 主模块挂的「取数桥」:doc() / editor() / range()
|
|
17
|
+
// window.__ccvSelRefCapture 本模块挂出去的「记一笔」入口,主模块喊这一声
|
|
18
|
+
//
|
|
19
|
+
// 分工(设计稿第十节):**主文件只挂桥、不搬逻辑**;算位置、存记录、写翻译
|
|
20
|
+
// 全部留在本文件里。所以主文件那侧永远只有几行,不会随本功能一起长胖。
|
|
21
|
+
//
|
|
22
|
+
// 侧模块与 apply 的先后顺序**不固定**(两边都是异步启动),故双向握手:
|
|
23
|
+
// · 主模块先 apply → 侧模块加载时看到 __ccvCtx,立刻自举
|
|
24
|
+
// · 侧模块先加载 → 挂出 __ccvSelRefBoot,等主模块 apply 时来调
|
|
25
|
+
//
|
|
26
|
+
// ── 分步实施(设计稿第十一节)──────────────────────────────────────
|
|
27
|
+
// 第 1 步【已完成 20:19】空壳 + 加载自检 + 握手 + 依赖探活
|
|
28
|
+
// 第 2 步【已完成 20:32】注册引用源 + 假候选 + 假翻译
|
|
29
|
+
// 实测:菜单出「话布选区」→ 点选出胶囊 → 发送被翻译
|
|
30
|
+
// 第 3 步【已完成】接真实选区数据 + 真翻译
|
|
31
|
+
// 第 4 步【已完成】自动两步:点「问AI」→ 替你开菜单 → 替你选中 → 胶囊直接进输入框
|
|
32
|
+
// 第 5 步【方案转向】定位不再写进引用正文,改由 host 侧的 canvas_locate 工具按需回答;
|
|
33
|
+
// 「第几处」是唯一跨端一致的位置信号(顺带把过期校验一起做了)
|
|
34
|
+
// 第 6 步【已完成】AI 改完**跳过去给你看**:轮询服务端记下的改动 → 切文档(需要的话)
|
|
35
|
+
// → 在画布里找到改动处 → 滚动 + 选中,主人当场核对
|
|
36
|
+
// ⚠️ 这一步依赖 host 新增的 /api/canvas/changes 端点 → **必须重启 dsh web**
|
|
37
|
+
// 第 7 步【2026-09-15】插入胶囊不再依赖菜单:改成直接派发官方的会话级事件
|
|
38
|
+
// ('slash/input-insert-reference',带草稿版本校验)。
|
|
39
|
+
// 起因:草稿里已经有内容时,菜单会被输入状态刷新带关,
|
|
40
|
+
// 第二条引用必然失败 → 退到一条会拍平已有胶囊的老路。
|
|
41
|
+
// 第 8 步【本版】把最后的兜底也改成"往末尾追加纯文字"(官方 insert-text 事件),
|
|
42
|
+
// 并且**严禁在草稿里已有胶囊时整体重写草稿** ——
|
|
43
|
+
// 整体重写时胶囊只剩纯文字形态,等于把主人前面所有引用毁掉。
|
|
44
|
+
//
|
|
45
|
+
// 健壮性约定:与 docdrop / doclink 相同 —— 防重复安装、失败静默降级、
|
|
46
|
+
// 任何异常都不许影响画布本体。
|
|
47
|
+
;(function () {
|
|
48
|
+
try {
|
|
49
|
+
if (window.__ccvSelRefInstalled) return
|
|
50
|
+
window.__ccvSelRefInstalled = true
|
|
51
|
+
|
|
52
|
+
var VERSION = 7
|
|
53
|
+
|
|
54
|
+
// 日志:优先借用主模块的共用实现,取不到就自带一份同样的 POST。
|
|
55
|
+
function log(msg) {
|
|
56
|
+
var kit = window.__ccvKit
|
|
57
|
+
if (kit && kit.log) { kit.log(msg, "selref"); return }
|
|
58
|
+
try {
|
|
59
|
+
fetch("/api/canvas/log", {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { "Content-Type": "application/json" },
|
|
62
|
+
body: JSON.stringify({ line: "[selref] " + msg })
|
|
63
|
+
}).catch(function () {})
|
|
64
|
+
} catch (_) {}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 三个依赖服务:sessions(拿会话作用域)、conversation(输入框句柄)、
|
|
68
|
+
// inputTriggers(注册引用源 —— 新方案的地基)。
|
|
69
|
+
// ⚠️ 它们必须在主模块的 inject 里声明,运行时 ctx.get 是取不到的。
|
|
70
|
+
function missingServices(ctx) {
|
|
71
|
+
if (!ctx) return ["<空上下文>"]
|
|
72
|
+
var probe = {
|
|
73
|
+
sessions: function () { return ctx.sessions || (ctx.get && ctx.get("sessions")) },
|
|
74
|
+
conversation: function () { return ctx.conversation || (ctx.get && ctx.get("conversation")) },
|
|
75
|
+
inputTriggers: function () { return ctx.inputTriggers || (ctx.get && ctx.get("inputTriggers")) }
|
|
76
|
+
}
|
|
77
|
+
var out = []
|
|
78
|
+
for (var k in probe) {
|
|
79
|
+
var v = null
|
|
80
|
+
try { v = probe[k]() } catch (_) { v = null }
|
|
81
|
+
if (!v) out.push(k)
|
|
82
|
+
}
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ═══ 一、记录表(编号 → 选区详情)═══════════════════════════════
|
|
87
|
+
// 第一版只存浏览器内存,刷新即清空(设计稿第七节:不落盘、不设 TTL)。
|
|
88
|
+
// 为什么可以这样:翻译发生在浏览器里,而刷新后输入框里的胶囊本身也会
|
|
89
|
+
// 退化成复制文本(设计稿风险 3),不会出现"胶囊还在但记录没了"的哑弹。
|
|
90
|
+
var records = []
|
|
91
|
+
var nextCode = 1
|
|
92
|
+
var MAX_RECORDS = 20 // 防失控上限(设计稿第十二节)
|
|
93
|
+
var LABEL_MAX = 12 // 胶囊显示文字上限,太长会撑爆输入框
|
|
94
|
+
var lastCandCount = -1 // 只为「菜单条数变化时记一行日志」,不参与业务
|
|
95
|
+
|
|
96
|
+
function shrink(s, max) {
|
|
97
|
+
s = String(s || "").replace(/\s+/g, " ").trim()
|
|
98
|
+
return s.length > max ? s.slice(0, max) + "…" : s
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function findRecord(code) {
|
|
102
|
+
for (var i = 0; i < records.length; i++) {
|
|
103
|
+
if (records[i].code === code) return records[i]
|
|
104
|
+
}
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ═══ 二、把编辑区摊平成纯文字 ═══════════════════════════════════
|
|
109
|
+
// 为什么要自己摊:DOM 的 Range.toString() **不含块与块之间的换行**,
|
|
110
|
+
// 而我们要报的"第几行 / 前后文"必须按人看到的行来算。
|
|
111
|
+
// 做法:从编辑区根节点深度遍历;遇到块级元素补一个换行;
|
|
112
|
+
// 边遍历边记下每个节点在整串里的起始下标(后面 O(1) 定位选区)。
|
|
113
|
+
var BLOCK_TAGS = {
|
|
114
|
+
DIV: 1, P: 1, LI: 1, UL: 1, OL: 1, BLOCKQUOTE: 1, PRE: 1,
|
|
115
|
+
H1: 1, H2: 1, H3: 1, H4: 1, H5: 1, H6: 1,
|
|
116
|
+
TABLE: 1, THEAD: 1, TBODY: 1, TR: 1, TD: 1, TH: 1,
|
|
117
|
+
SECTION: 1, ARTICLE: 1, FIGURE: 1, FIGCAPTION: 1, HR: 1
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function flatText(root) {
|
|
121
|
+
var text = ""
|
|
122
|
+
var startOf = new Map() // 任意节点 → 它在整串里的起始下标
|
|
123
|
+
function closeBlock(isBlock) {
|
|
124
|
+
if (isBlock && text.length && text.charAt(text.length - 1) !== "\n") text += "\n"
|
|
125
|
+
}
|
|
126
|
+
function walk(node) {
|
|
127
|
+
if (node.nodeType === 3) { startOf.set(node, text.length); text += node.data; return }
|
|
128
|
+
if (node.nodeType !== 1) return
|
|
129
|
+
var tag = node.tagName
|
|
130
|
+
if (tag === "SCRIPT" || tag === "STYLE") return
|
|
131
|
+
if (tag === "BR") { startOf.set(node, text.length); text += "\n"; return }
|
|
132
|
+
var isBlock = !!BLOCK_TAGS[tag]
|
|
133
|
+
closeBlock(isBlock) // 块前的分隔换行
|
|
134
|
+
startOf.set(node, text.length) // 记在换行之后,两端才自洽
|
|
135
|
+
for (var i = 0; i < node.childNodes.length; i++) walk(node.childNodes[i])
|
|
136
|
+
closeBlock(isBlock) // 块后的分隔换行
|
|
137
|
+
}
|
|
138
|
+
walk(root)
|
|
139
|
+
return { text: text, startOf: startOf }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 选区起点在整串里的下标。端点是文字节点(绝大多数情况)直接查表;
|
|
143
|
+
// 端点是元素节点(三击选整段之类)就先看它指向的那个子节点。
|
|
144
|
+
function offsetOf(flat, container, offset) {
|
|
145
|
+
if (!container) return -1
|
|
146
|
+
if (container.nodeType === 3) {
|
|
147
|
+
var base = flat.startOf.get(container)
|
|
148
|
+
return (base === undefined) ? -1 : base + (offset || 0)
|
|
149
|
+
}
|
|
150
|
+
if (container.nodeType === 1) {
|
|
151
|
+
var kids = container.childNodes
|
|
152
|
+
var child = (offset >= 0 && offset < kids.length) ? kids[offset] : null
|
|
153
|
+
if (child) {
|
|
154
|
+
var b = flat.startOf.get(child)
|
|
155
|
+
if (b !== undefined) return b
|
|
156
|
+
}
|
|
157
|
+
var s = flat.startOf.get(container)
|
|
158
|
+
if (s === undefined) return -1
|
|
159
|
+
return s + String(container.textContent || "").length
|
|
160
|
+
}
|
|
161
|
+
return -1
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 最近的小标题:取"起点在选区之前"的最后一个标题;没有就空着。
|
|
165
|
+
function nearestHeading(editor, flat, offset) {
|
|
166
|
+
try {
|
|
167
|
+
var hs = editor.querySelectorAll("h1,h2,h3,h4,h5,h6")
|
|
168
|
+
var best = ""
|
|
169
|
+
for (var i = 0; i < hs.length; i++) {
|
|
170
|
+
var s = flat.startOf.get(hs[i])
|
|
171
|
+
if (s === undefined) continue
|
|
172
|
+
if (offset >= 0 && s > offset) break
|
|
173
|
+
best = String(hs[i].textContent || "").replace(/\s+/g, " ").trim()
|
|
174
|
+
}
|
|
175
|
+
return best
|
|
176
|
+
} catch (_) { return "" }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ═══ 三、「记一笔」:把当前选区算成一条记录 ═════════════════════
|
|
180
|
+
// 主模块在点「问AI」的那一刻喊一声(__ccvSelRefCapture),我们从桥上取数。
|
|
181
|
+
// ⚠️ 这一刻过去之后浏览器选区就塌了(输入框抢焦点),所以必须当场算完。
|
|
182
|
+
// 某段文字在全文里出现几次(允许重叠:`aaaa` 里的 `aa` 要数出 3 次)。
|
|
183
|
+
// ⚠️ 只用来决定引用标签里要不要标「多处」——**定位本身不在这里做**:
|
|
184
|
+
// 那交给 host 侧的 canvas_locate 工具(模型按需来问,它读的是当前文档,
|
|
185
|
+
// 顺带还能回答"这段引用是不是已经失效")。
|
|
186
|
+
function countOcc(text, needle) {
|
|
187
|
+
if (!needle) return 0
|
|
188
|
+
var n = 0, i = 0
|
|
189
|
+
while ((i = text.indexOf(needle, i)) >= 0) { n++; i += 1 }
|
|
190
|
+
return n
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 用户选中的那处**是第几处**(按出现顺序,从 1 数)。
|
|
194
|
+
// 这是**唯一不靠前后文就能把位置说清**的信息 —— 而且它跨端口径一致:
|
|
195
|
+
// 渲染前后文字的出现顺序不变,所以客户端(按渲染结果数)与 host 侧
|
|
196
|
+
// (按 Markdown 源码数)数出来的"第几处"是同一个数。
|
|
197
|
+
// 对不上(比如选区起点落在某个匹配的中间)就返回 0,此时引用里只标「多处」。
|
|
198
|
+
function ordinalOf(text, needle, off) {
|
|
199
|
+
if (!needle || off < 0) return 0
|
|
200
|
+
var n = 0, i = 0
|
|
201
|
+
while ((i = text.indexOf(needle, i)) >= 0) {
|
|
202
|
+
n++
|
|
203
|
+
if (i === off) return n
|
|
204
|
+
if (i > off) return 0
|
|
205
|
+
i += 1
|
|
206
|
+
}
|
|
207
|
+
return 0
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function capture() {
|
|
211
|
+
try {
|
|
212
|
+
var br = window.__ccvSelRefBridge
|
|
213
|
+
if (!br) { log("capture: 主模块的取数桥还没挂上,本次不记录"); return null }
|
|
214
|
+
|
|
215
|
+
var doc = null, editor = null, range = null
|
|
216
|
+
try { doc = br.doc ? br.doc() : null } catch (_) {}
|
|
217
|
+
try { editor = br.editor ? br.editor() : null } catch (_) {}
|
|
218
|
+
try { range = br.range ? br.range() : null } catch (_) {}
|
|
219
|
+
|
|
220
|
+
if (!doc || !doc.id) { log("capture: 当前没有打开的话布,本次不记录"); return null }
|
|
221
|
+
if (!editor || !range) { log("capture: 取不到选区,本次不记录"); return null }
|
|
222
|
+
|
|
223
|
+
var selected = String(range.toString() || "")
|
|
224
|
+
if (!selected.trim()) { log("capture: 选区是空的,本次不记录"); return null }
|
|
225
|
+
|
|
226
|
+
var flat = flatText(editor)
|
|
227
|
+
var off = offsetOf(flat, range.startContainer, range.startOffset)
|
|
228
|
+
|
|
229
|
+
var line = -1
|
|
230
|
+
if (off >= 0) line = flat.text.slice(0, off).split("\n").length
|
|
231
|
+
|
|
232
|
+
// 原文在全文出现几次 —— 只用来决定"要不要在标签里标「多处」"。
|
|
233
|
+
// 真正的定位交给 host 侧的 canvas_locate 工具:模型按需来问,
|
|
234
|
+
// 那边读的是**当前**文档,顺带还能判断这段引用是不是已经失效。
|
|
235
|
+
var dup = (off >= 0) ? countOcc(flat.text, selected) : 0
|
|
236
|
+
// 「你选的是第几处」—— 有歧义时才算,没歧义时这个数没有意义
|
|
237
|
+
var ord = (dup > 1) ? ordinalOf(flat.text, selected, off) : 0
|
|
238
|
+
|
|
239
|
+
var rec = {
|
|
240
|
+
code: nextCode++,
|
|
241
|
+
at: Date.now(),
|
|
242
|
+
doc: {
|
|
243
|
+
id: doc.id,
|
|
244
|
+
title: doc.title || "(未命名)",
|
|
245
|
+
// mention 是主模块按官方引号规则算好的 @"绝对路径",直接沿用不另写一份
|
|
246
|
+
mention: doc.mention || "",
|
|
247
|
+
version: doc.version
|
|
248
|
+
},
|
|
249
|
+
selected: selected,
|
|
250
|
+
length: selected.length,
|
|
251
|
+
offset: off,
|
|
252
|
+
line: line,
|
|
253
|
+
heading: off >= 0 ? nearestHeading(editor, flat, off) : "",
|
|
254
|
+
dupCount: dup,
|
|
255
|
+
// 用户选的是第几处(0 = 没对上,比如选区起点落在某个匹配的中间)
|
|
256
|
+
ordinal: ord
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
records.unshift(rec) // 新的排最前
|
|
260
|
+
if (records.length > MAX_RECORDS) records.length = MAX_RECORDS
|
|
261
|
+
|
|
262
|
+
log("已记录选区 #" + rec.code + ":《" + rec.doc.title + "》"
|
|
263
|
+
+ (rec.heading ? " · " + rec.heading : "")
|
|
264
|
+
+ (rec.line > 0 ? " · 第 " + rec.line + " 行" : " · 位置未取到")
|
|
265
|
+
+ " · " + rec.length + " 字「" + shrink(rec.selected, 16) + "」"
|
|
266
|
+
+ (rec.dupCount > 1
|
|
267
|
+
? " ⚠️ 原文在全文出现 " + rec.dupCount + " 次"
|
|
268
|
+
+ (rec.ordinal > 0 ? ",你选的是第 " + rec.ordinal + " 处" : "(序数没对上,只标「多处」)")
|
|
269
|
+
+ " → 随引用一起报给服务端(正文里已经不写坐标了)"
|
|
270
|
+
: ""))
|
|
271
|
+
pinUp(rec)
|
|
272
|
+
return rec
|
|
273
|
+
} catch (e) {
|
|
274
|
+
log("capture 失败: " + (e && e.message))
|
|
275
|
+
return null
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── 把「这一段到底是第几处」单独报给服务端 ──────────────────────
|
|
280
|
+
// 为什么非要单独报:引用正文里已经不写坐标了(主人要求卡片只留文件名),
|
|
281
|
+
// 而"第几处"只有**划选那一刻**在浏览器里才算得出来(靠 DOM 选区位置),
|
|
282
|
+
// 服务端自己无论如何算不出 —— 它只知道原文是什么,不知道你选的是哪一处。
|
|
283
|
+
//
|
|
284
|
+
// 时机:capture() 成功时立刻送。比"用户按发送"早得多(中间还要打字),
|
|
285
|
+
// 所以服务端那边早就存好了,**不存在"消息先到、记录后到"的竞态**。
|
|
286
|
+
// (这正是当初不敢在 serialize 时才上报的原因 —— 那一步离发送太近。)
|
|
287
|
+
// 送不出去不影响任何事:服务端找不到这份记录时会退化成「分不出是哪一处,
|
|
288
|
+
// 请让用户确认」,那是安全的降级,不是错误。
|
|
289
|
+
function pinUp(rec) {
|
|
290
|
+
try {
|
|
291
|
+
var p = fetch("/api/canvas/selref/pin", {
|
|
292
|
+
method: "POST",
|
|
293
|
+
headers: { "content-type": "application/json" },
|
|
294
|
+
body: JSON.stringify({
|
|
295
|
+
code: rec.code,
|
|
296
|
+
docId: rec.doc.id,
|
|
297
|
+
path: bareMention(rec.doc.mention),
|
|
298
|
+
title: rec.doc.title || "",
|
|
299
|
+
text: rec.selected,
|
|
300
|
+
ordinal: rec.ordinal || 0,
|
|
301
|
+
dupCount: rec.dupCount || 0,
|
|
302
|
+
line: rec.line || -1,
|
|
303
|
+
heading: rec.heading || ""
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
if (p && typeof p.catch === "function") p.catch(function () {})
|
|
307
|
+
} catch (e) { /* 送不出去就算了,服务端按"没收到"降级 */ }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// mention 是主模块算好的 @"绝对路径"(带 @ 和一对引号),服务端要的是裸路径
|
|
311
|
+
function bareMention(m) {
|
|
312
|
+
var s = String(m || "")
|
|
313
|
+
if (s.charAt(0) === "@") s = s.slice(1)
|
|
314
|
+
if (s.length >= 2 && s.charAt(0) === '"' && s.charAt(s.length - 1) === '"') s = s.slice(1, -1)
|
|
315
|
+
return s
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ═══ 四、引用源定义 ═════════════════════════════════════════════
|
|
319
|
+
// 官方机制:想让输入框里出现胶囊,必须先在 inputTriggers 里挂一个 source。
|
|
320
|
+
// 官方自带 @文件 / @会话 两个 —— 这里是第三个。
|
|
321
|
+
//
|
|
322
|
+
// ⚠️ name 有两个身份:① 菜单分组标题 ② 发送时翻译路由的 key
|
|
323
|
+
// (草稿里每个胶囊记着自己的 source,提交时按它找回本源的 codec)。
|
|
324
|
+
// 它必须与官方及任何其他插件不重名,否则 registerSource 直接抛错。
|
|
325
|
+
var SOURCE_NAME = "话布选区"
|
|
326
|
+
|
|
327
|
+
// 胶囊的「出厂设置」。**只有这一处** —— 点菜单插入和直接插入都用它,
|
|
328
|
+
// 否则两条路出来的胶囊会长得不一样(显示的字、退化后的文字都不同)。
|
|
329
|
+
function chipPayload(rec) {
|
|
330
|
+
if (!rec) return { source: SOURCE_NAME, ref: "", label: "话布选区", appearance: "file", clipboardText: "@" + SOURCE_NAME }
|
|
331
|
+
return {
|
|
332
|
+
source: SOURCE_NAME,
|
|
333
|
+
ref: String(rec.code),
|
|
334
|
+
// 胶囊上显示的字:选中文字的前 12 字
|
|
335
|
+
label: shrink(rec.selected, LABEL_MAX),
|
|
336
|
+
appearance: "file", // 图标:官方只给 file / folder / session
|
|
337
|
+
// 刷新页面后持久化草稿里的胶囊会退化成这行纯文字(设计稿风险 3),
|
|
338
|
+
// 所以写成人类也读得懂的「@文档 > 选中文字」,退化后仍可用。
|
|
339
|
+
clipboardText: "@" + rec.doc.title + " > " + shrink(rec.selected, 40)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function makeSource() {
|
|
344
|
+
return {
|
|
345
|
+
trigger: "@",
|
|
346
|
+
name: SOURCE_NAME,
|
|
347
|
+
order: 50, // 排在官方 @文件 / @会话 之后(它们默认 0)
|
|
348
|
+
showGroupTitle: true, // 菜单里显示分组标题,即上面的 name
|
|
349
|
+
candidates: function () {
|
|
350
|
+
// 一条记录 = 一个候选。菜单显示可以长一点(20 字),
|
|
351
|
+
// 真正进输入框的胶囊再缩到 12 字。
|
|
352
|
+
var items = records.map(function (r) {
|
|
353
|
+
var bits = [r.doc.title]
|
|
354
|
+
if (r.heading) bits.push(r.heading)
|
|
355
|
+
if (r.line > 0) bits.push("第 " + r.line + " 行")
|
|
356
|
+
return {
|
|
357
|
+
name: shrink(r.selected, 20),
|
|
358
|
+
description: bits.join(" · "),
|
|
359
|
+
icon: "file",
|
|
360
|
+
value: String(r.code)
|
|
361
|
+
}
|
|
362
|
+
})
|
|
363
|
+
// 菜单每敲一个字都会来取一次,只在条数变化时记一行 ——
|
|
364
|
+
// 否则「菜单空着」这种情况没法区分是没记到还是没注册上。
|
|
365
|
+
if (items.length !== lastCandCount) {
|
|
366
|
+
lastCandCount = items.length
|
|
367
|
+
log("菜单取候选:当前 " + items.length + " 条"
|
|
368
|
+
+ (items.length ? "" : "(还没记过选区 —— 先在话布里划一段字、点「问AI」)"))
|
|
369
|
+
}
|
|
370
|
+
return Promise.resolve(items)
|
|
371
|
+
},
|
|
372
|
+
// 选中一项 → 交给管线插入胶囊。这里返回的每一栏都是胶囊的「出厂设置」。
|
|
373
|
+
onPick: function (pick) {
|
|
374
|
+
var raw = pick && pick.candidate ? pick.candidate.value : ""
|
|
375
|
+
var code = parseInt(String(raw), 10)
|
|
376
|
+
var rec = isNaN(code) ? null : findRecord(code)
|
|
377
|
+
log("onPick 命中,ref=" + raw + (rec ? "(《" + rec.doc.title + "》" + (rec.line > 0 ? " 第 " + rec.line + " 行" : "") + ")" : "(记录已不在,走兜底)"))
|
|
378
|
+
return { insert: chipPayload(rec) }
|
|
379
|
+
},
|
|
380
|
+
// codec = 翻译规则。发送时官方按 source 找到它,把胶囊换成真正发给模型的文字。
|
|
381
|
+
// ⚠️ 必须永不抛错:官方默认「翻译失败就拦住发送」,而发不出去比不精确更糟。
|
|
382
|
+
codec: {
|
|
383
|
+
clipboardText: function (ref) {
|
|
384
|
+
var code = parseInt(String(ref), 10)
|
|
385
|
+
var rec = isNaN(code) ? null : findRecord(code)
|
|
386
|
+
return rec ? "@" + rec.doc.title + " > " + shrink(rec.selected, 40) : "@" + SOURCE_NAME
|
|
387
|
+
},
|
|
388
|
+
serialize: function (ref) {
|
|
389
|
+
// try/catch 是**功能要求**不是防御性编程:任何一条漏网异常都会
|
|
390
|
+
// 让用户的消息发不出去,那比"定位不精确"严重得多。
|
|
391
|
+
try {
|
|
392
|
+
var code = parseInt(String(ref), 10)
|
|
393
|
+
var rec = isNaN(code) ? null : findRecord(code)
|
|
394
|
+
if (!rec) {
|
|
395
|
+
log("serialize: 找不到记录 " + ref + "(胶囊可能是刷新页面之前的),走兜底")
|
|
396
|
+
return Promise.resolve("【话布选区】那段选区的记录已经没了(编号 " + String(ref)
|
|
397
|
+
+ ")。请让用户重新划一次,不要凭猜测定位。")
|
|
398
|
+
}
|
|
399
|
+
// 输出以**短**为准,而且形状要能被折起来。
|
|
400
|
+
// 主人给的关键线索:老流程那套 `@"路径" > 说明` 在对话里会**折成一张文件卡片**
|
|
401
|
+
// (显示成「文件名 › 说明」),前提是 **@ 必须打头**。
|
|
402
|
+
//
|
|
403
|
+
// 🔴 2026-09-15 主人第二次收短(原话:「这还是有很长一段话啊,
|
|
404
|
+
// 可以不可以【二、到底发生了什么 · 第 21 行 · 多处(第 2 处)】这些都不要,都不显示」):
|
|
405
|
+
// **标签整串去掉**,卡片上只留文件名。理由:那是**给模型看的坐标**,
|
|
406
|
+
// 主人自己不需要在聊天里读它,而且它把卡片撑得很难看。
|
|
407
|
+
//
|
|
408
|
+
// ⚠️ 代价与**配套改动**(两处必须一起改,只改一边会让模型彻底失去定位能力):
|
|
409
|
+
// 这串标签以前是模型判断"是哪一处"的唯一线索。去掉之后改由
|
|
410
|
+
// ① 下面的 pinUp() 把「你选的是第几处」单独报给服务端;
|
|
411
|
+
// ② 服务端在注入时用它(src/host/13-selref-context.js)。
|
|
412
|
+
//
|
|
413
|
+
// 仍然只有两行:
|
|
414
|
+
// · 第 1 行 = @路径(@ 打头才会折成文件卡片,卡片标题就是文件名)
|
|
415
|
+
// · 第 2 行 = 原文(放过长或多行的原文,不塞进卡片那行)
|
|
416
|
+
var head
|
|
417
|
+
if (rec.doc.mention) {
|
|
418
|
+
head = rec.doc.mention
|
|
419
|
+
} else {
|
|
420
|
+
// 拿不到真实路径(旧 host 没给 path)时退回纯文字抬头,至少不丢信息
|
|
421
|
+
head = "【话布选区】" + (rec.doc.title || "(未命名)")
|
|
422
|
+
}
|
|
423
|
+
var out = head + "\n「" + rec.selected + "」"
|
|
424
|
+
|
|
425
|
+
// 正文里不再有定位信息。真正的"到底是哪一处"由服务端**主动注入**给模型 ——
|
|
426
|
+
// 那边读的是**当前**文档,顺带把"这段引用是不是已经失效"也一并告诉它。
|
|
427
|
+
log("serialize 已完成 #" + rec.code + ":共 " + out.length + " 字"
|
|
428
|
+
+ (rec.dupCount > 1 ? "(原文重复 " + rec.dupCount + " 次,序数改走上报通道)" : ""))
|
|
429
|
+
return Promise.resolve(out)
|
|
430
|
+
} catch (e) {
|
|
431
|
+
log("serialize 异常(已降级,不拦发送): " + (e && e.message))
|
|
432
|
+
return Promise.resolve("【话布选区】翻译时出错,已降级。请让用户重新划一次要引用的段落。")
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ═══ 五、自动两步(点「问AI」→ 替你开菜单 → 替你选中)═════════════
|
|
440
|
+
// 官方把这两个口子用在**它自己的 `/` 命令菜单**上("传进来的位置是程序合成的,
|
|
441
|
+
// 而不是用户敲出来的"),所以这是官方预留的程序入口,不是钻空子。
|
|
442
|
+
// 最终插入走的是正门 —— 和用户自己打 @、自己点一下,是同一条代码路径。
|
|
443
|
+
//
|
|
444
|
+
// 唯一的技术难点:候选是**异步取**的 —— 菜单先 pending 再 ready,
|
|
445
|
+
// 而 pick 在 ready 之前会**静默返回、什么都不做**。
|
|
446
|
+
// 所以必须等它 ready 再点,这就是设计稿风险 2 说的"打开与选中之间隔着一次异步取候选"。
|
|
447
|
+
|
|
448
|
+
// 会话手柄:sessions.scope(sessionId) → 会话作用域 → inputTriggers.sessionOf(它)。
|
|
449
|
+
// 三段任缺一段返回 null(不抛错),调用方按"自动失败"处理。
|
|
450
|
+
// 会话作用域。官方那四个「改动输入框」的事件就挂在它上面
|
|
451
|
+
// (见 dsh-client-ui-conversation 里 actx.on("slash/input-…")),
|
|
452
|
+
// 所以拿到它 = 拿到了官方认可的改动入口。
|
|
453
|
+
function sessionScope() {
|
|
454
|
+
var ctx = window.__ccvSelRefCtx
|
|
455
|
+
var sid = window.__ccvSessionId
|
|
456
|
+
if (!ctx || !sid) return null
|
|
457
|
+
var sessions = null
|
|
458
|
+
try { sessions = ctx.sessions || (ctx.get && ctx.get("sessions")) } catch (_) {}
|
|
459
|
+
if (!sessions || typeof sessions.scope !== "function") return null
|
|
460
|
+
try { return sessions.scope(sid) || null } catch (_) { return null }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function pipelineController() {
|
|
464
|
+
var ctx = window.__ccvSelRefCtx
|
|
465
|
+
if (!ctx) return null
|
|
466
|
+
var triggers = null
|
|
467
|
+
try { triggers = ctx.inputTriggers || (ctx.get && ctx.get("inputTriggers")) } catch (_) {}
|
|
468
|
+
var actx = sessionScope()
|
|
469
|
+
if (!triggers || !actx) return null
|
|
470
|
+
try { return triggers.sessionOf(actx) } catch (_) { return null }
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ── 直接往输入框里放东西:走官方**会话级事件**,不必开菜单 ──────────
|
|
474
|
+
// 依据(读官方源码与类型契约得来,不是猜的):
|
|
475
|
+
// · 官方把「插入胶囊」「插入纯文字」都定义成带版本校验的会话级事件
|
|
476
|
+
// ('slash/input-insert-reference' / 'slash/input-insert-text',bail 模式),
|
|
477
|
+
// 并且明说「业务插件拿到的就是这些事件」;
|
|
478
|
+
// · 事件处理体就是 shell.insertReference(ref, span) / insertText(text, span),
|
|
479
|
+
// 跟"用户自己打 @ 再点一下"最终走的是**同一个函数**;
|
|
480
|
+
// · span 是 {start, end, draftRev}:**版本号对不上就拒绝执行**(CAS)。
|
|
481
|
+
//
|
|
482
|
+
// 为什么值得换这条路(2026-09-15 主人第二次报同一个毛病):
|
|
483
|
+
// 原来必须"先开菜单 → 等候选就绪 → 再点",而菜单只要被任何一次
|
|
484
|
+
// 输入状态刷新碰上就会自己关掉。草稿里已经有东西时更容易被关,
|
|
485
|
+
// 于是第二条引用必然失败、退到那条会**把已有胶囊拍平**的老路。
|
|
486
|
+
// 直接派发事件没有菜单,也就没有"被关掉"这回事。
|
|
487
|
+
function dispatchInput(ev, req) {
|
|
488
|
+
var actx = sessionScope()
|
|
489
|
+
if (!actx || typeof actx.bail !== "function") {
|
|
490
|
+
log("派发 " + ev + ":拿不到会话作用域,或它没有 bail")
|
|
491
|
+
return false
|
|
492
|
+
}
|
|
493
|
+
try {
|
|
494
|
+
// 官方自己也是 actx.bail(actx, 事件, 请求) 这么派发的(第一个参数是派发主体)
|
|
495
|
+
return actx.bail(actx, ev, req) === true
|
|
496
|
+
} catch (e) {
|
|
497
|
+
log("派发 " + ev + " 异常: " + (e && e.message))
|
|
498
|
+
return false
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// 当前草稿与它的版本号。版本号必须**派发前现读** —— 官方拿它做比对,
|
|
503
|
+
// 读到旧的就会被拒(这是好事:拒绝总比插到错地方强)。
|
|
504
|
+
function draftNow() {
|
|
505
|
+
var st = window.__ccvInputState
|
|
506
|
+
if (!st || typeof st.draft !== "string" || typeof st.draftRev !== "number") return null
|
|
507
|
+
return { text: st.draft, rev: st.draftRev, occurrences: st.occurrences }
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// 🔴🔴 官方有**两套坐标**,而插件只拿得到其中一套 —— 这就是"第一条能插、第二条插不进"
|
|
511
|
+
// 的真正原因(2026-09-15 主人现场发现"手动 @ 能插第二条"之后定位到的):
|
|
512
|
+
//
|
|
513
|
+
// · 给插件看的 `InputState.draft` = **剪贴板投影**:胶囊**展开**成 `@标题 > 摘要`
|
|
514
|
+
// · 改输入框用的 span 却是**探测投影**(detect):
|
|
515
|
+
// 一颗胶囊在那儿只算 **1 个字符**(`\uFFFC`,见官方 $composerLayout 的 pushLeaf)
|
|
516
|
+
//
|
|
517
|
+
// 草稿为空时两套坐标一样长 → 第一条永远成功;
|
|
518
|
+
// 一旦有一颗胶囊,两者就差「胶囊文字长度 − 1」→ 我们算出的"末尾"**越过了文末**,
|
|
519
|
+
// 官方 applyEdit 落不下来 → 返回 false。**所以是必然失败,不是偶发。**
|
|
520
|
+
//
|
|
521
|
+
// 换算:探测末尾 = draft 长度 − Σ(每颗胶囊的 length − 1)
|
|
522
|
+
// `occurrences[].length` 正是那颗胶囊在 draft 里的长度。
|
|
523
|
+
// 对照官方 detectOffsetOfClipboardOffset:落在最末尾时返回的恰好是 detectLength。✅
|
|
524
|
+
function detectLen(draft, occurrences) {
|
|
525
|
+
var len = String(draft || "").length
|
|
526
|
+
if (occurrences && occurrences.length) {
|
|
527
|
+
for (var i = 0; i < occurrences.length; i++) {
|
|
528
|
+
var o = occurrences[i]
|
|
529
|
+
var L = o && o.length
|
|
530
|
+
if (typeof L === "number" && L > 1) len -= (L - 1)
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return len < 0 ? 0 : len
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// 零宽选区:起止都在**草稿末尾**(用探测坐标,不是 draft 长度)。
|
|
537
|
+
// 🔴 这个"零宽 + 落在末尾"是本功能不破坏已有内容的关键:
|
|
538
|
+
// 官方是"把 span 换成胶囊",span 是零宽的就等于**只往末尾追加**,
|
|
539
|
+
// 前面已有的囊一个字都不会动。
|
|
540
|
+
function tailSpan(d) {
|
|
541
|
+
var at = d.text.length
|
|
542
|
+
var det = detectLen(d.text, d.occurrences)
|
|
543
|
+
// 没有胶囊时 det === at,与从前的行为完全一致
|
|
544
|
+
if (det !== at) log("坐标换算:剪贴板末尾 " + at + " → 探测末尾 " + det
|
|
545
|
+
+ "(" + ((d.occurrences || []).length) + " 颗胶囊)")
|
|
546
|
+
return { start: det, end: det, draftRev: d.rev }
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// 直接插入胶囊。成功 = 官方返回 true **且**胶囊数真的多了一个
|
|
550
|
+
// (官方返回 true 只代表"编辑器接受并应用了这次编辑",仍要数一遍才作数)。
|
|
551
|
+
function insertChipDirect(rec) {
|
|
552
|
+
return new Promise(function (resolve) {
|
|
553
|
+
try {
|
|
554
|
+
if (!rec) return resolve(false)
|
|
555
|
+
var d = draftNow()
|
|
556
|
+
if (!d) return resolve(false)
|
|
557
|
+
var before = chipCount()
|
|
558
|
+
var ok = dispatchInput("slash/input-insert-reference", {
|
|
559
|
+
reference: chipPayload(rec),
|
|
560
|
+
span: tailSpan(d)
|
|
561
|
+
})
|
|
562
|
+
log("直接插入:事件返回 " + String(ok) + "(草稿 " + d.text.length + " 字,版本 " + d.rev
|
|
563
|
+
+ ",已有胶囊 " + before + " 条)")
|
|
564
|
+
if (!ok) return resolve(false)
|
|
565
|
+
setTimeout(function () {
|
|
566
|
+
var after = chipCount()
|
|
567
|
+
if (after > before) { log("直接插入成功:胶囊进了输入框(" + before + " → " + after + ")"); return resolve(true) }
|
|
568
|
+
log("直接插入:事件说成功了,但胶囊数没变(" + before + " → " + after + ")")
|
|
569
|
+
resolve(false)
|
|
570
|
+
}, VERIFY_MS)
|
|
571
|
+
} catch (e) {
|
|
572
|
+
log("直接插入异常: " + (e && e.message))
|
|
573
|
+
resolve(false)
|
|
574
|
+
}
|
|
575
|
+
})
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// 最后一道兜底:往末尾插**纯文字**(同样走官方事件,同样零宽、只追加)。
|
|
579
|
+
// 主模块的老做法是 setDraft 整体重写,而草稿里的胶囊在整体重写时
|
|
580
|
+
// 只会剩下纯文字形态 —— **已有胶囊会被一起拍平**。这条不会。
|
|
581
|
+
// @returns {boolean} true = 确实插进去了
|
|
582
|
+
function insertPlainTextDirect(body) {
|
|
583
|
+
try {
|
|
584
|
+
var d = draftNow()
|
|
585
|
+
if (!d) { log("兜底插纯文字:读不到草稿,放弃"); return false }
|
|
586
|
+
var ok = dispatchInput("slash/input-insert-text", { text: String(body), span: tailSpan(d) })
|
|
587
|
+
log("兜底插纯文字:事件返回 " + String(ok))
|
|
588
|
+
return ok
|
|
589
|
+
} catch (e) {
|
|
590
|
+
log("兜底插纯文字异常: " + (e && e.message))
|
|
591
|
+
return false
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function groupOf(state, name) {
|
|
596
|
+
var gs = (state && state.groups) || []
|
|
597
|
+
for (var i = 0; i < gs.length; i++) if (gs[i] && gs[i].source === name) return gs[i]
|
|
598
|
+
return null
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// 草稿里"胶囊"的条数(官方 occurrences = 引用出现位置的清单)。
|
|
602
|
+
// 用它判断"点下去了到底插进去没有" —— 比猜可靠。
|
|
603
|
+
function chipCount() {
|
|
604
|
+
var st = window.__ccvInputState
|
|
605
|
+
return (st && st.occurrences) ? st.occurrences.length : -1
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
var READY_WAIT_MS = 1200 // 等菜单就绪的上限
|
|
609
|
+
var VERIFY_MS = 180 // 点完之后等输入机 + 渲染一轮,再数胶囊
|
|
610
|
+
var POLL_MS = 25
|
|
611
|
+
// 菜单从「喊它开」到「快照里真的开着」之间的宽限。
|
|
612
|
+
//
|
|
613
|
+
// 🔴 2026-09-15 实测踩到:草稿为空时一切正常(胶囊稳稳插进去);草稿里**已经有
|
|
614
|
+
// 一条引用**之后再引用,就报「菜单没打开(或被立刻关掉了)」→ 悄悄退化成本文。
|
|
615
|
+
// 当时的写法是 `toggleSource(...)` 之后**同步**看一眼快照,看到没开就直接判失败,
|
|
616
|
+
// 连轮询都没起 —— 只要菜单是「晚一拍」才出现在快照里,就必然踩中。
|
|
617
|
+
// 所以这里给它一段时间把状态刷上来,超时才认输。
|
|
618
|
+
var OPEN_GRACE_MS = 600
|
|
619
|
+
|
|
620
|
+
// 失败时把现场记全 —— 主人开不了控制台,日志是唯一的诊断通道。
|
|
621
|
+
// 只记「没打开」这一个结果,下次还是查不出原因(菜单后来开了?一直没开?开着别家的?)
|
|
622
|
+
function snapInfo(s) {
|
|
623
|
+
try {
|
|
624
|
+
if (!s) return "(快照是空的)"
|
|
625
|
+
var gs = []
|
|
626
|
+
var arr = s.groups || []
|
|
627
|
+
for (var i = 0; i < arr.length; i++) {
|
|
628
|
+
var g = arr[i]
|
|
629
|
+
gs.push(String(g && g.source) + ":" + String(g && g.status)
|
|
630
|
+
+ "(" + ((g && g.items) ? g.items.length : 0) + ")")
|
|
631
|
+
}
|
|
632
|
+
return " [open=" + s.open + " groups=[" + gs.join(", ") + "]]"
|
|
633
|
+
} catch (e) { return "(快照读不出来)" }
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* 替用户走官方两步。
|
|
638
|
+
* @returns {Promise<boolean>} true = 胶囊确实进了输入框;false = 没成(调用方退回老做法)。
|
|
639
|
+
* 无论哪条路都**不抛错** —— 它是主路上的一个可选加速,不能反过来把主路弄坏。
|
|
640
|
+
*/
|
|
641
|
+
function autoInsert() {
|
|
642
|
+
return new Promise(function (resolve) {
|
|
643
|
+
var settled = false
|
|
644
|
+
var stopPoll = null
|
|
645
|
+
var timer = null
|
|
646
|
+
function done(ok, why) {
|
|
647
|
+
if (settled) return
|
|
648
|
+
settled = true
|
|
649
|
+
if (stopPoll) stopPoll()
|
|
650
|
+
if (timer) clearTimeout(timer)
|
|
651
|
+
if (why) log("自动插入没成:" + why)
|
|
652
|
+
resolve(ok)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
try {
|
|
656
|
+
var ctl = pipelineController()
|
|
657
|
+
if (!ctl) return done(false, "拿不到输入管线的会话手柄(sessionId 或服务还没到位)")
|
|
658
|
+
if (!records.length) return done(false, "还没有记过选区")
|
|
659
|
+
if (!ctl.menu || typeof ctl.menu.getSnapshot !== "function") return done(false, "控制器形状和预期不一样")
|
|
660
|
+
if (typeof ctl.toggleSource !== "function" || typeof ctl.pick !== "function") return done(false, "控制器缺 toggleSource / pick")
|
|
661
|
+
|
|
662
|
+
var st = window.__ccvInputState
|
|
663
|
+
var draft = (st && typeof st.draft === "string") ? st.draft : ""
|
|
664
|
+
var rev = st ? st.draftRev : undefined
|
|
665
|
+
if (typeof rev !== "number") return done(false, "读不到草稿版本号(输入框状态还没挂上)")
|
|
666
|
+
|
|
667
|
+
// 合成一个"零宽选区",口子开在草稿末尾。
|
|
668
|
+
// position 的算法照抄官方 toggleCommandMenu:光标之前全是空白才算 leading。
|
|
669
|
+
var at = draft.length
|
|
670
|
+
var hit = {
|
|
671
|
+
trigger: "@",
|
|
672
|
+
query: "",
|
|
673
|
+
quoted: false,
|
|
674
|
+
position: draft.slice(0, at).trim() === "" ? "leading" : "inline",
|
|
675
|
+
span: { start: at, end: at, draftRev: rev }
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
var before = chipCount()
|
|
679
|
+
var openedAt = Date.now()
|
|
680
|
+
|
|
681
|
+
function look() {
|
|
682
|
+
if (settled) return "done"
|
|
683
|
+
var s = null
|
|
684
|
+
try { s = ctl.menu.getSnapshot() } catch (_) {}
|
|
685
|
+
if (!s || !s.open) {
|
|
686
|
+
// ⚠️ 不能在这里立刻判负,理由见 OPEN_GRACE_MS 的注释
|
|
687
|
+
if (Date.now() - openedAt < OPEN_GRACE_MS) return "pending"
|
|
688
|
+
done(false, "菜单没打开(或被立刻关掉了)" + snapInfo(s))
|
|
689
|
+
return "done"
|
|
690
|
+
}
|
|
691
|
+
var g = groupOf(s, SOURCE_NAME)
|
|
692
|
+
if (!g || g.status !== "ready") return "pending" // 还没就绪,接着等
|
|
693
|
+
if (!g.items || !g.items.length) { done(false, "候选是空的" + snapInfo(s)); return "done" }
|
|
694
|
+
stopPoll()
|
|
695
|
+
log("自动插入:菜单已就绪,选中第 1 条(共 " + g.items.length + " 条)")
|
|
696
|
+
ctl.pick(SOURCE_NAME, 0)
|
|
697
|
+
// pick 是同步派发事件,但插入要等输入机处理完、界面再渲染一轮,
|
|
698
|
+
// 所以隔一小会儿数胶囊 —— 数出来才算真的成功。
|
|
699
|
+
timer = setTimeout(function () {
|
|
700
|
+
if (chipCount() > before) { log("自动插入成功:胶囊已进输入框"); return done(true) }
|
|
701
|
+
done(false, "点下去了,但草稿里没多出胶囊(版本对不上?)"
|
|
702
|
+
+ " [before=" + before + " after=" + chipCount() + "]")
|
|
703
|
+
}, VERIFY_MS)
|
|
704
|
+
return "ready"
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
log("自动插入:打开「" + SOURCE_NAME + "」菜单(草稿 " + draft.length + " 字,版本 " + rev
|
|
708
|
+
+ ",口子在 " + at + ",位置 " + hit.position + ",已有胶囊 " + before + " 条)")
|
|
709
|
+
ctl.toggleSource(SOURCE_NAME, hit)
|
|
710
|
+
|
|
711
|
+
if (look() !== "pending") return // 要么已经在等校验,要么已经失败收场
|
|
712
|
+
var waited = 0
|
|
713
|
+
stopPoll = interval(function () {
|
|
714
|
+
waited += POLL_MS
|
|
715
|
+
if (look() !== "pending") return
|
|
716
|
+
if (waited >= READY_WAIT_MS) {
|
|
717
|
+
try { ctl.dismiss() } catch (_) {}
|
|
718
|
+
done(false, "等了 " + READY_WAIT_MS + " 毫秒菜单还没就绪,已把菜单关掉")
|
|
719
|
+
}
|
|
720
|
+
}, POLL_MS)
|
|
721
|
+
} catch (e) {
|
|
722
|
+
done(false, "自动插入异常: " + (e && e.message))
|
|
723
|
+
}
|
|
724
|
+
})
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function interval(fn, ms) {
|
|
728
|
+
var id = setInterval(fn, ms)
|
|
729
|
+
return function () { clearInterval(id) }
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function delay(ms) {
|
|
733
|
+
return new Promise(function (r) { setTimeout(r, ms) })
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// 走菜单那条路失败就**再试一次**,别急着降级。
|
|
737
|
+
//
|
|
738
|
+
// 🔴 为什么要重试(2026-09-15 实测):菜单那条路失败几乎只发生在
|
|
739
|
+
// 「草稿里已经有内容」时,而一旦降级,主模块会用 setDraft 把整份草稿
|
|
740
|
+
// **按纯文本重写** —— 纯文本里胶囊只剩 `@标题 > 摘要` 这种样子,
|
|
741
|
+
// 于是**已有的胶囊会被一起拍平**。主人的体感就是「发一条有胶囊,
|
|
742
|
+
// 再发一条胶囊全没了」。重试的代价只是多等几百毫秒,
|
|
743
|
+
// 降级的代价是毁掉他前面所有的引用 —— 划不来。
|
|
744
|
+
//
|
|
745
|
+
// ⚠️ 现在这条路已经降级成**次选**了:首选是上面那条"直接派发官方事件",
|
|
746
|
+
// 它不需要菜单,所以压根没有"菜单被关掉"这个失败源。
|
|
747
|
+
// 菜单这条路留着是为了在"事件派发不出去"的环境(更老的 DSH)里还能用。
|
|
748
|
+
var RETRY_DELAY_MS = 300
|
|
749
|
+
|
|
750
|
+
function menuInsertWithRetry() {
|
|
751
|
+
return autoInsert().then(function (ok) {
|
|
752
|
+
if (ok) return true
|
|
753
|
+
// 先把可能残留的菜单收掉,再重来 —— 否则第二次 toggleSource 会变成"关掉"
|
|
754
|
+
try { var c = pipelineController(); if (c && c.dismiss) c.dismiss() } catch (_) {}
|
|
755
|
+
return delay(RETRY_DELAY_MS).then(function () {
|
|
756
|
+
log("走菜单:第一次没成,重试一次")
|
|
757
|
+
return autoInsert()
|
|
758
|
+
})
|
|
759
|
+
})
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// 主入口:把「刚划的那一段」变成输入框里的一个胶囊。
|
|
763
|
+
//
|
|
764
|
+
// 三条路,从最稳到最退:① 直接派发官方事件(首选,不经菜单)
|
|
765
|
+
// ② 走菜单替你点一下(老路,留着兼容)
|
|
766
|
+
// 都失败就返回 false —— 由主模块决定怎么办,**本模块绝不自己去改草稿全文**。
|
|
767
|
+
//
|
|
768
|
+
// @param rec 可选。不传就用最近一次 capture 的那条(正常情况就是它)。
|
|
769
|
+
function autoInsertWithRetry(rec) {
|
|
770
|
+
var use = rec || records[0] || null
|
|
771
|
+
return insertChipDirect(use).then(function (ok) {
|
|
772
|
+
if (ok) return true
|
|
773
|
+
log("直接插入没成,改走「替你开菜单点一下」那条路")
|
|
774
|
+
return menuInsertWithRetry()
|
|
775
|
+
})
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// ═══ 六、AI 改完,跳过去给你看 ═════════════════════════════════
|
|
779
|
+
// 需求(主人 2026-09-15 原话):「不能直接在画布上操作吗,直接跳转到选区进行操作」
|
|
780
|
+
// → 选定落点:**「改完跳过去给我看」** —— AI 改完文档,画布自动跳到改动处,
|
|
781
|
+
// 主人当场核对它改对没有,不用自己翻半天找。
|
|
782
|
+
//
|
|
783
|
+
// 谁在盯着:服务端在 applyWrite 里把"改前/改后"一比,记下改动区间(只记 AI 改的);
|
|
784
|
+
// 这里**轮询**取回来。用轮询不用推送的理由见 host 侧那个端点的注释。
|
|
785
|
+
//
|
|
786
|
+
// ⚠️ 最关键的坑:服务端给的是 **Markdown 源码**里的文字,这里手里是**渲染之后**
|
|
787
|
+
// 的文字 —— 两边的记号(`**` `#` 之类)和空白形态完全不同,直接找必然找不到。
|
|
788
|
+
// 所以**两边按同一套规则先"归一化"**(把记号与空白都丢掉)再找,
|
|
789
|
+
// 命中位置再靠一张映射表换回渲染文字里的真实位置。
|
|
790
|
+
|
|
791
|
+
// 归一化时一律丢掉的字符:Markdown 记号 + 空白。
|
|
792
|
+
// 必须是**同一套**规则用在两边 —— 规则一致,两边丢掉的东西才一致,才能对上。
|
|
793
|
+
var SQUASH_DROP = " \t\r\n*_~`#>|[]()!\\"
|
|
794
|
+
|
|
795
|
+
// 一段文字 → 只剩实义字符的"键"(服务端给的锚点用这个)
|
|
796
|
+
function squashKey(s) {
|
|
797
|
+
var out = "", i, ch
|
|
798
|
+
s = String(s || "")
|
|
799
|
+
for (i = 0; i < s.length; i++) {
|
|
800
|
+
ch = s.charAt(i)
|
|
801
|
+
if (SQUASH_DROP.indexOf(ch) >= 0) continue
|
|
802
|
+
out += ch
|
|
803
|
+
}
|
|
804
|
+
return out
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// 同上,但**记住每个字符原来在第几位**(渲染文字这一侧用)。
|
|
808
|
+
// 只有这一侧需要映射:另一边只是拿来搜的键,不需要换回位置。
|
|
809
|
+
function squashMapped(s) {
|
|
810
|
+
var out = "", map = [], i, ch
|
|
811
|
+
for (i = 0; i < s.length; i++) {
|
|
812
|
+
ch = s.charAt(i)
|
|
813
|
+
if (SQUASH_DROP.indexOf(ch) >= 0) continue
|
|
814
|
+
out += ch
|
|
815
|
+
map.push(i)
|
|
816
|
+
}
|
|
817
|
+
return { s: out, map: map }
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// 摊平文本的下标 → DOM 里的位置。**offsetOf 的反向操作**:
|
|
821
|
+
// 同一张 startOf 表倒着查 —— 找"起点不超过 idx、而且覆盖得到 idx"的那个文字节点。
|
|
822
|
+
function nodeAt(flat, idx) {
|
|
823
|
+
var best = null, bestStart = -1
|
|
824
|
+
flat.startOf.forEach(function (start, node) {
|
|
825
|
+
if (node.nodeType !== 3) return
|
|
826
|
+
if (start > idx) return // 整个节点都在目标之后
|
|
827
|
+
if (start + node.data.length < idx) return // 整个节点都在目标之前
|
|
828
|
+
if (start > bestStart) { bestStart = start; best = node }
|
|
829
|
+
})
|
|
830
|
+
return best ? { node: best, offset: idx - bestStart } : null
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// 在渲染文字里找"改动点在哪"。
|
|
834
|
+
// 三重线索**按可靠性顺序**试:
|
|
835
|
+
// ① 改动后的新文字 —— 命中点就是改动处(最准)
|
|
836
|
+
// ② 改动点之前的文字 —— 改动点紧跟在它后面(服务端从公共前缀里取的,一定存在)
|
|
837
|
+
// ③ 改动点之后的文字 —— 改动点紧挨在它前面
|
|
838
|
+
//
|
|
839
|
+
// 🔴 但「找得到」不等于「找得对」:线索在全文出现两次以上时会撞到别的地方,
|
|
840
|
+
// 而**跳到错的地方比不跳更糟** —— 主人看到一段没变的文字,会以为 AI 改的就是那儿。
|
|
841
|
+
// 所以**优先取"全文唯一"的那一条**;三条都不唯一时才退回"最靠前的那条",
|
|
842
|
+
// 并把 unique=false 带出去,让调用方在提示里说实话(别假装很准)。
|
|
843
|
+
// @returns {{pos:number, unique:boolean}|null} null = 三条线索一条都找不到 → **不跳**
|
|
844
|
+
function findChangePos(editor, rec) {
|
|
845
|
+
var flat = flatText(editor)
|
|
846
|
+
var sq = squashMapped(flat.text)
|
|
847
|
+
if (!sq.s) return null
|
|
848
|
+
|
|
849
|
+
function countOf(n) {
|
|
850
|
+
var c = 0, i = 0
|
|
851
|
+
while ((i = sq.s.indexOf(n, i)) >= 0) { c++; i++ }
|
|
852
|
+
return c
|
|
853
|
+
}
|
|
854
|
+
// 线索用完之后的下标 → 摊平文本里的位置(锚点之后的那个字)
|
|
855
|
+
function posAfter(i, n) {
|
|
856
|
+
var e = i + n.length
|
|
857
|
+
return e < sq.map.length ? sq.map[e] : flat.text.length
|
|
858
|
+
}
|
|
859
|
+
function posAt(i) { return sq.map[i] }
|
|
860
|
+
|
|
861
|
+
var cands = []
|
|
862
|
+
var ak = squashKey(rec.added)
|
|
863
|
+
if (ak) cands.push({ key: ak, pick: function (i) { return posAt(i) } })
|
|
864
|
+
var hk = squashKey(rec.head)
|
|
865
|
+
if (hk) cands.push({ key: hk, pick: function (i) { return posAfter(i, hk) } })
|
|
866
|
+
var tk = squashKey(rec.tail)
|
|
867
|
+
if (tk) cands.push({ key: tk, pick: function (i) { return posAt(i) } })
|
|
868
|
+
|
|
869
|
+
var fallback = null
|
|
870
|
+
for (var i = 0; i < cands.length; i++) {
|
|
871
|
+
var idx = sq.s.indexOf(cands[i].key)
|
|
872
|
+
if (idx < 0) continue // 这条线索对不上,试下一条
|
|
873
|
+
var pos = cands[i].pick(idx)
|
|
874
|
+
if (fallback === null) fallback = { pos: pos, unique: false }
|
|
875
|
+
if (countOf(cands[i].key) === 1) return { pos: pos, unique: true } // 全文唯一 → 可信
|
|
876
|
+
}
|
|
877
|
+
return fallback
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// 滚动到改动点并选中它,让主人一眼看见。
|
|
881
|
+
// 选中范围:从改动点到**该行末尾**(改一处通常就在一行里),最多 120 字。
|
|
882
|
+
// 为什么不去凑一个"精确的改动区间":渲染后的长度和源码长度本来就不一样,
|
|
883
|
+
// 假装精确反而会选错地方 —— 选到整行是**不会错**的粒度。
|
|
884
|
+
function revealAt(editor, idx) {
|
|
885
|
+
var flat = flatText(editor)
|
|
886
|
+
var text = flat.text
|
|
887
|
+
var to = text.indexOf("\n", idx)
|
|
888
|
+
if (to < 0 || to - idx > 120) to = Math.min(text.length, idx + 120)
|
|
889
|
+
var a = nodeAt(flat, idx)
|
|
890
|
+
if (!a) return false
|
|
891
|
+
var b = (to > idx) ? nodeAt(flat, to) : null
|
|
892
|
+
try {
|
|
893
|
+
var rng = document.createRange()
|
|
894
|
+
rng.setStart(a.node, a.offset)
|
|
895
|
+
if (b) rng.setEnd(b.node, b.offset)
|
|
896
|
+
else rng.setEnd(a.node, a.offset)
|
|
897
|
+
var holder = a.node.parentElement || a.node.parentNode
|
|
898
|
+
if (holder && holder.scrollIntoView) {
|
|
899
|
+
holder.scrollIntoView({ behavior: "smooth", block: "center" })
|
|
900
|
+
}
|
|
901
|
+
var sel = document.getSelection()
|
|
902
|
+
if (sel) { sel.removeAllRanges(); sel.addRange(rng) }
|
|
903
|
+
return true
|
|
904
|
+
} catch (e) {
|
|
905
|
+
log("跳转失败(选中没建成): " + (e && e.message))
|
|
906
|
+
return false
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function bridgeEditor() {
|
|
911
|
+
var br = window.__ccvSelRefBridge
|
|
912
|
+
if (!br || !br.editor) return null
|
|
913
|
+
try { return br.editor() } catch (_) { return null }
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function toast(msg, key) {
|
|
917
|
+
try {
|
|
918
|
+
var kit = window.__ccvKit
|
|
919
|
+
if (kit && kit.toast) kit.toast(msg, key)
|
|
920
|
+
} catch (_) {}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// 等改动处在画布里出现 —— 用于"刚切完文档,等它渲染出来再跳"。
|
|
924
|
+
// 为什么不用固定延时:切文档要拉网络、React 还要重渲染一轮,耗时不定,
|
|
925
|
+
// 固定睡一会儿要么不够要么白等。**直接盯着目标出现**最稳,而且顺便自校验。
|
|
926
|
+
function waitForPos(rec, timeoutMs) {
|
|
927
|
+
return new Promise(function (resolve) {
|
|
928
|
+
var waited = 0, step = 150
|
|
929
|
+
function look() {
|
|
930
|
+
var ed = bridgeEditor()
|
|
931
|
+
if (ed) {
|
|
932
|
+
var hit = findChangePos(ed, rec)
|
|
933
|
+
if (hit) return resolve(hit)
|
|
934
|
+
}
|
|
935
|
+
waited += step
|
|
936
|
+
if (waited >= timeoutMs) return resolve(null)
|
|
937
|
+
setTimeout(look, step)
|
|
938
|
+
}
|
|
939
|
+
look()
|
|
940
|
+
})
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// 处理一条改动通知。**任何情况都不抛错** —— 它只是个"看得见"的便利,
|
|
944
|
+
// 出问题也绝不能影响画布本体。
|
|
945
|
+
function handleChange(rec) {
|
|
946
|
+
try {
|
|
947
|
+
if (!rec || !rec.canvasId) return Promise.resolve(false)
|
|
948
|
+
if (window.__ccvJumpOff) { log("跳转总开关是关的,本次不跳"); return Promise.resolve(false) }
|
|
949
|
+
var br = window.__ccvSelRefBridge
|
|
950
|
+
if (!br || !br.editor) return Promise.resolve(false) // 面板没开,没什么可跳
|
|
951
|
+
|
|
952
|
+
// 主人正在编辑区里打字 → **本轮不跳**。
|
|
953
|
+
// 跳转会挪走滚动位置、还会抢掉他的选区,正在输入的人会直接骂。
|
|
954
|
+
// 宁可慢一拍,也不能打断输入。
|
|
955
|
+
try {
|
|
956
|
+
if (br.isEditing && br.isEditing()) {
|
|
957
|
+
log("AI 改了《" + rec.title + "》,但主人正在打字 → 本轮不跳(不打断输入)")
|
|
958
|
+
return Promise.resolve(false)
|
|
959
|
+
}
|
|
960
|
+
} catch (_) {}
|
|
961
|
+
|
|
962
|
+
var cur = null
|
|
963
|
+
try { cur = br.doc ? br.doc() : null } catch (_) {}
|
|
964
|
+
|
|
965
|
+
// 改动可能落在**别的**文档里(主人在看 A,AI 改了 B)→ 先把 B 打开
|
|
966
|
+
var needOpen = !cur || cur.id !== rec.canvasId
|
|
967
|
+
if (needOpen) {
|
|
968
|
+
if (!br.openDoc) { log("想跳到《" + rec.title + "》,但没有切文档的口子"); return Promise.resolve(false) }
|
|
969
|
+
var started = false
|
|
970
|
+
try { started = br.openDoc(rec.canvasId) } catch (_) {}
|
|
971
|
+
if (!started) {
|
|
972
|
+
log("切不到《" + rec.title + "》(文档可能已被关掉或删掉),本次不跳")
|
|
973
|
+
return Promise.resolve(false)
|
|
974
|
+
}
|
|
975
|
+
log("AI 改的是《" + rec.title + "》,正在切过去…")
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
return waitForPos(rec, needOpen ? JUMP_WAIT_MS : 400).then(function (hit) {
|
|
979
|
+
if (!hit) {
|
|
980
|
+
log("没能在画布里找到改动处(两边文字对不上),本次不跳 —— 不猜着跳")
|
|
981
|
+
return false
|
|
982
|
+
}
|
|
983
|
+
var ed = bridgeEditor()
|
|
984
|
+
if (!ed) return false
|
|
985
|
+
var line = flatText(ed).text.slice(0, hit.pos).split("\n").length
|
|
986
|
+
if (!revealAt(ed, hit.pos)) return false
|
|
987
|
+
log("已跳到《" + rec.title + "》改动处(画面上第 " + line + " 行)"
|
|
988
|
+
+ (hit.unique ? "" : " ⚠️ 锚点在全文不止一处,位置可能不准")
|
|
989
|
+
+ " 删除 " + rec.removedChars + " 字 / 新增 " + rec.addedChars + " 字")
|
|
990
|
+
// 不唯一时**如实说**"可能不准" —— 提示里假装很准,等于骗主人去看错的地方
|
|
991
|
+
toast(hit.unique
|
|
992
|
+
? "AI 刚改了《" + rec.title + "》,已跳到改动处"
|
|
993
|
+
: "AI 刚改了《" + rec.title + "》,已跳到大概位置(请核对)", "ccv-ai-jump")
|
|
994
|
+
return true
|
|
995
|
+
})
|
|
996
|
+
} catch (e) {
|
|
997
|
+
log("跳转处理异常(已忽略): " + (e && e.message))
|
|
998
|
+
return Promise.resolve(false)
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
var POLL_MS = 1200 // 轮询间隔。AI 改完到主人看向屏幕本来就有间隔,这点延迟无感
|
|
1003
|
+
var JUMP_WAIT_MS = 4000 // 切完文档后等它渲染出来的上限
|
|
1004
|
+
var lastSeq = 0
|
|
1005
|
+
var primed = false // 第一次只对齐游标,不为"页面加载之前"的改动跳
|
|
1006
|
+
var busy = false
|
|
1007
|
+
|
|
1008
|
+
function poll() {
|
|
1009
|
+
if (busy) return Promise.resolve()
|
|
1010
|
+
busy = true
|
|
1011
|
+
// fetch 可能整个不存在(老浏览器 / 测试沙箱)→ 必须同步兜住,
|
|
1012
|
+
// 否则会在定时器里抛出去,把整个模块带崩。
|
|
1013
|
+
var req = null
|
|
1014
|
+
try { req = fetch("/api/canvas/changes?since=" + lastSeq) } catch (_) { req = null }
|
|
1015
|
+
if (!req) { busy = false; return Promise.resolve() }
|
|
1016
|
+
return Promise.resolve(req)
|
|
1017
|
+
.then(function (r) { return r.json() })
|
|
1018
|
+
.then(function (d) {
|
|
1019
|
+
if (!d || !d.ok || typeof d.latest !== "number") return
|
|
1020
|
+
var list = d.changes || []
|
|
1021
|
+
lastSeq = d.latest
|
|
1022
|
+
if (!primed) { primed = true; return } // 首次只对齐,不跳
|
|
1023
|
+
if (!list.length) return
|
|
1024
|
+
// 一轮里 AI 可能连着改好几处 → **只跳最后一条**。
|
|
1025
|
+
// 跳来跳去只会让人晕,而最后一条通常就是最该核对的那处。
|
|
1026
|
+
return handleChange(list[list.length - 1])
|
|
1027
|
+
})
|
|
1028
|
+
.catch(function () {}) // 网络抖动无所谓,下一轮再问
|
|
1029
|
+
.then(function () { busy = false })
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function startPolling() {
|
|
1033
|
+
// 首次延迟一下:页面刚起来时前面的请求还没完,别挤在一起。
|
|
1034
|
+
setTimeout(function () {
|
|
1035
|
+
poll()
|
|
1036
|
+
setInterval(poll, POLL_MS)
|
|
1037
|
+
}, 900)
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// ═══ 五、自举 ═══════════════════════════════════════════════════
|
|
1041
|
+
|
|
1042
|
+
// 「记一笔」这个口子与引用源注册无关,越早挂越好 ——
|
|
1043
|
+
// 万一注册失败(比如名字撞了),至少记录还在,日志能看出是这个功能在跑。
|
|
1044
|
+
window.__ccvSelRefCapture = capture
|
|
1045
|
+
// 「把这段引用变成输入框里的胶囊」的入口,由主模块在点「问AI」时调用。
|
|
1046
|
+
// 返回 Promise<boolean>:false 时主模块才会去走兜底。
|
|
1047
|
+
// 传参可选:刚记下的那条记录(不传就用最近一条)。
|
|
1048
|
+
window.__ccvSelRefAuto = autoInsertWithRetry
|
|
1049
|
+
// 兜底入口:往输入框末尾追加**纯文字**,走官方事件、零宽、只追加。
|
|
1050
|
+
// 主模块的老兜底是"整体重写草稿",那个会把已有胶囊拍平;
|
|
1051
|
+
// 所以主模块必须先试这个,只有输入框里一个胶囊都没有时才允许整体重写。
|
|
1052
|
+
// @returns {boolean} true = 插进去了,别再动草稿
|
|
1053
|
+
window.__ccvSelRefText = insertPlainTextDirect
|
|
1054
|
+
// 诊断用:想看看现在记了哪几条,控制台敲 __ccvSelRefInfo()
|
|
1055
|
+
window.__ccvSelRefInfo = function () {
|
|
1056
|
+
return records.map(function (r) {
|
|
1057
|
+
return "#" + r.code + " 《" + r.doc.title + "》" + (r.heading ? " · " + r.heading : "")
|
|
1058
|
+
+ (r.line > 0 ? " · 第 " + r.line + " 行" : "") + " · " + r.length + " 字"
|
|
1059
|
+
})
|
|
1060
|
+
}
|
|
1061
|
+
// 诊断 + 回归测试用:把编辑区摊平的结果(纯文字 + 下标表)暴露出来。
|
|
1062
|
+
// 行号算错不会报错、只会安静地错,所以这个口子必须留着,测试靠它核对。
|
|
1063
|
+
window.__ccvSelRefFlat = flatText
|
|
1064
|
+
|
|
1065
|
+
// 「AI 改完跳过去给你看」的诊断口(这个功能最容易"静默不生效"——
|
|
1066
|
+
// 两边文字对不上时它只是不跳,界面毫无异常,所以必须能单独问它):
|
|
1067
|
+
// __ccvSelRefPoll() 立刻问一次服务端,不用等轮询
|
|
1068
|
+
// __ccvSelRefJump(rec) 手工喂一条改动记录,直接看跳转效果
|
|
1069
|
+
// __ccvSelRefSquash(s) 看一段文字归一化之后长什么样(两边对不上时靠它比对)
|
|
1070
|
+
// __ccvSelRefFind(rec[, editor]) 返回 {pos, unique} 或 null(null = 找不到,本次不会跳)
|
|
1071
|
+
// 给 editor 就用它,不给就用当前编辑区(测试/排障都要)
|
|
1072
|
+
// __ccvJumpOff = true 总开关,排障时临时整体停掉跳转
|
|
1073
|
+
window.__ccvSelRefPoll = poll
|
|
1074
|
+
window.__ccvSelRefJump = handleChange
|
|
1075
|
+
window.__ccvSelRefSquash = squashKey
|
|
1076
|
+
window.__ccvSelRefFind = function (rec, editor) {
|
|
1077
|
+
var ed = editor || bridgeEditor()
|
|
1078
|
+
return ed ? findChangePos(ed, rec) : null
|
|
1079
|
+
}
|
|
1080
|
+
window.__ccvSelRefNodeAt = nodeAt
|
|
1081
|
+
// 两套坐标的换算(2026-09-15 新增)。单独挂出来是因为它一旦算错,
|
|
1082
|
+
// 表现是"第一条能插、第二条插不进"这种**只在有胶囊时才出现**的怪事,
|
|
1083
|
+
// 排障时最需要能单独问它一遍。
|
|
1084
|
+
window.__ccvSelRefDetectLen = detectLen
|
|
1085
|
+
|
|
1086
|
+
// 轮询「AI 刚改了哪儿」—— 与引用源注册无关,所以放在自举之外:
|
|
1087
|
+
// 就算注册失败(名字撞了之类),"跳过去给我看"这一半照样能用。
|
|
1088
|
+
startPolling()
|
|
1089
|
+
|
|
1090
|
+
var booted = false
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* 主模块 apply(ctx) 时调用;本模块也可能自己先走到这里。
|
|
1094
|
+
* @param {object} ctx - cordis 根上下文
|
|
1095
|
+
*/
|
|
1096
|
+
function boot(ctx) {
|
|
1097
|
+
if (booted) {
|
|
1098
|
+
if (window.__ccvSelRefCtx !== ctx) {
|
|
1099
|
+
log("上下文已更换(插件疑似重挂),本模块不重复注册 —— 刷新页面即可")
|
|
1100
|
+
}
|
|
1101
|
+
return
|
|
1102
|
+
}
|
|
1103
|
+
booted = true
|
|
1104
|
+
|
|
1105
|
+
try {
|
|
1106
|
+
if (!ctx) { log("boot 收到空上下文,放弃自举"); return }
|
|
1107
|
+
|
|
1108
|
+
window.__ccvSelRefCtx = ctx
|
|
1109
|
+
window.__ccvSelRefReady = true
|
|
1110
|
+
|
|
1111
|
+
var missing = missingServices(ctx)
|
|
1112
|
+
if (missing.length) {
|
|
1113
|
+
log("v" + VERSION + " 启动时暂缺: " + missing.join(" / ") + "(2 秒后复检)")
|
|
1114
|
+
setTimeout(function () {
|
|
1115
|
+
var again = missingServices(ctx)
|
|
1116
|
+
if (!again.length) log("复检通过:三个服务已全部就绪 → 注册被跳过,刷新页面即可生效")
|
|
1117
|
+
else log("复检仍缺: " + again.join(" / ") + " → 检查主模块 inject 声明")
|
|
1118
|
+
}, 2000)
|
|
1119
|
+
return
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
log("v" + VERSION + " 已就绪:sessions / conversation / inputTriggers 三个服务都在")
|
|
1123
|
+
|
|
1124
|
+
// ─── 注册引用源 ───────────────────────────────────────────
|
|
1125
|
+
var register = function () { return ctx.inputTriggers.registerSource(makeSource()) }
|
|
1126
|
+
try {
|
|
1127
|
+
if (typeof ctx.effect === "function") ctx.effect(register, "collab-canvas: @ " + SOURCE_NAME)
|
|
1128
|
+
else register()
|
|
1129
|
+
log("已注册「" + SOURCE_NAME + "」引用源(真数据)"
|
|
1130
|
+
+ " → 划词点「问AI」记一笔,再打 @ 就能看到它")
|
|
1131
|
+
} catch (e) {
|
|
1132
|
+
log("注册引用源失败: " + (e && e.message)
|
|
1133
|
+
+ "(若提示 already registered,说明名字与别的源撞了)")
|
|
1134
|
+
}
|
|
1135
|
+
} catch (e) {
|
|
1136
|
+
log("boot 失败: " + (e && e.message))
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
if (window.__ccvCtx) {
|
|
1141
|
+
// 主模块已经 apply 过了
|
|
1142
|
+
boot(window.__ccvCtx)
|
|
1143
|
+
} else {
|
|
1144
|
+
// 主模块还没 apply —— 挂出去等它来调
|
|
1145
|
+
window.__ccvSelRefBoot = boot
|
|
1146
|
+
log("v" + VERSION + " 已加载,等待主模块上下文")
|
|
1147
|
+
// 兜底自检:主模块始终没来就留个痕迹,便于区分「没加载」和「加载了但没握手」
|
|
1148
|
+
setTimeout(function () {
|
|
1149
|
+
if (!booted) log("v" + VERSION + " 仍在等待:3 秒内未收到主模块上下文(__ccvCtx 未挂)")
|
|
1150
|
+
}, 3000)
|
|
1151
|
+
}
|
|
1152
|
+
} catch (e) {
|
|
1153
|
+
// 最外层兜底:连 log 都可能不可用,直接裸 POST
|
|
1154
|
+
try {
|
|
1155
|
+
fetch("/api/canvas/log", {
|
|
1156
|
+
method: "POST",
|
|
1157
|
+
headers: { "Content-Type": "application/json" },
|
|
1158
|
+
body: JSON.stringify({ line: "[selref] 模块执行异常: " + (e && e.message) })
|
|
1159
|
+
}).catch(function () {})
|
|
1160
|
+
} catch (_) {}
|
|
1161
|
+
}
|
|
1162
|
+
})()
|