dsh-composer-ease 0.0.0-stage → 0.9.66

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.
@@ -0,0 +1,320 @@
1
+ // ── 模块:补全弹层 ────────────────────────────────
2
+ // 弹层 DOM、候选渲染与行样式、光标定位、类型图标、以及本文词(contextWords)的收集。
3
+ // 本文件是 lib/client.js 的片段,由 tools/build-client.mjs 按序拼回;不要直接改生成物 lib/client.js。
4
+
5
+ var popup = { root: null, items: [], index: 0, open: false, sig: '', wm: null }
6
+ function ensurePopup() {
7
+ if (popup.root) return popup.root
8
+ // 同一页面可能被装了两份插件(本地路径 + GitHub 各一份):复用已有的同一个元素,
9
+ // 否则两套实例各画一个弹层,就成了「一个在输入框左上、一个在光标后」的两个框(实测)。
10
+ var exist = document.getElementById('dsh-cp-popup')
11
+ if (exist) { popup.root = exist; return exist }
12
+ var d = document.createElement('div')
13
+ d.id = 'dsh-cp-popup'
14
+ d.setAttribute('data-dsh-plugin', 'dsh-composer-ease')
15
+ d.style.cssText = 'position:fixed;z-index:2147480000;display:none;overflow-y:auto;overflow-x:hidden;' +
16
+ 'border:1px solid var(--dsw-alias-border-l2,#333);border-radius:8px;' +
17
+ 'background:var(--dsw-alias-bg-layer-2,#1e1e1e);color:var(--dsw-alias-label-primary,inherit);' +
18
+ 'font:var(--dsw-font-xs,12.5px)/1.6 ui-monospace,Consolas,monospace;box-shadow:0 8px 24px rgba(0,0,0,.35)'
19
+ document.body.appendChild(d)
20
+ popup.root = d
21
+ return d
22
+ }
23
+ /** 光标(caret)位置:弹层锚在光标上,而不是输入框左边缘 —— 否则一行长文时弹层会盖住半个视野。
24
+ * 只读地量,不往宿主 DOM 里塞临时节点(那会扰动 Lexical)。 */
25
+ function caretRect(ed) {
26
+ try {
27
+ if (!isText(ed)) {
28
+ var sel = window.getSelection()
29
+ if (sel && sel.rangeCount) {
30
+ var r = sel.getRangeAt(0).cloneRange()
31
+ var rects = r.getClientRects()
32
+ if (rects && rects.length) return rects[rects.length - 1]
33
+ var b = r.getBoundingClientRect()
34
+ if (b && (b.width || b.height || b.top)) return b
35
+ }
36
+ }
37
+ } catch (e) {}
38
+ return ed.getBoundingClientRect()
39
+ }
40
+ function itemsSignature(items) {
41
+ var out = []
42
+ for (var i = 0; i < items.length; i++) out.push(items[i].kind + ':' + items[i].value)
43
+ return out.join('|')
44
+ }
45
+ function escapeHtml(s) {
46
+ return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c] })
47
+ }
48
+ /** 命中字符加粗 —— VS Code 的建议列表就是这么标的(位置由服务端 fuzzyScore 给)。 */
49
+ function boldHtml(label, matches) {
50
+ var out = '', at = 0, s = String(label)
51
+ for (var i = 0; i < matches.length; i++) {
52
+ var idx = matches[i]
53
+ if (idx < at || idx >= s.length) continue
54
+ if (idx > at) out += escapeHtml(s.slice(at, idx))
55
+ out += '<b>' + escapeHtml(s[idx]) + '</b>'
56
+ at = idx + 1
57
+ }
58
+ if (at < s.length) out += escapeHtml(s.slice(at))
59
+ return out
60
+ }
61
+ /** 列表右侧那个淡淡的类型词(VS Code 也只在右侧挂一个短类型)。 */
62
+ function kindLabel(it) {
63
+ if (it.src === 'context') return '上下文'
64
+ if (it.src === 'composer') return '本文'
65
+ if (it.kind === 'file') return '文件'
66
+ if (it.kind === 'model') return '模型'
67
+ if (it.kind === 'snippet') return '片段'
68
+ var d = String(it.detail || '')
69
+ if (d.indexOf('标签') >= 0) return '元素'
70
+ if (d.indexOf('属性') >= 0) return '属性'
71
+ if (d.indexOf('关键字') >= 0) return '关键字'
72
+ if (d.indexOf('词表') >= 0) return '词表'
73
+ return ''
74
+ }
75
+ /** 类型图标(VS Code 的建议框左侧就是图标;小方块 + 首字,纯 SVG,不用图片资源)。 */
76
+ function kindIcon(it) {
77
+ var label = kindLabel(it)
78
+ var color = it.kind === 'file' ? '#7aa2f7' : (it.src === 'context' ? '#9ece6a' : (it.kind === 'model' ? '#e0af68' : '#9aa5b1'))
79
+ var ch = label ? label.slice(0, 1) : '·'
80
+ return '<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">' +
81
+ '<rect x="0.5" y="0.5" width="11" height="11" rx="2.5" fill="none" stroke="' + color + '" stroke-opacity="0.55"/>' +
82
+ '<text x="6" y="8.6" text-anchor="middle" font-size="7" fill="' + color + '">' + escapeHtml(ch) + '</text></svg>'
83
+ }
84
+
85
+ /** 只取**对话正文区**,不取整页(改)。
86
+ * 宿主给的锚点在 0.2.0-rc.2 的 dsh-client-ui-chat 里实测有两处:
87
+ * `[data-conversation-scroll]` 与 `[data-slot="conversation.chat.node"]`。
88
+ * 为什么不读 document.body:左侧栏与设置面板里的词不是「对话内容」,算进候选既跑题,
89
+ * 又会把弹层自己画出来的候选再喂回池子(自我喂养)。抓不到就回空串 —— 上层自然退回只看草稿,
90
+ * 不抛错、不往宿主 DOM 里写任何东西。 */
91
+ function conversationText() {
92
+ try {
93
+ var box = document.querySelector ? document.querySelector('[data-conversation-scroll]') : null
94
+ if (!box || !box.innerText) {
95
+ var node = document.querySelector ? document.querySelector('[data-slot="conversation.chat.node"]') : null
96
+ box = node ? (node.parentNode || null) : null
97
+ }
98
+ var txt = box && box.innerText ? String(box.innerText) : ''
99
+ return txt.length > 40 ? txt.slice(-12000) : ''
100
+ } catch (e) { return '' }
101
+ }
102
+
103
+ /** 上下文候选:把**对话正文区**里的标识符挖出来(只读,不动宿主 DOM)。
104
+ * 这是「提示内容跟上下文强关联」的来源:对话里出现过的词,打前缀就能补出来。2 秒缓存。 */
105
+ var ctxCache = { text: '', words: [], at: 0 }
106
+ function contextWords(ed) {
107
+ try {
108
+ var now = Date.now()
109
+ if (now - ctxCache.at < 2000 && ctxCache.words.length) return ctxCache.words
110
+ // 只吃对话正文(不裁剪:实测用 split(草稿) 抠自己人时,
111
+ // 「inst」是「install_quarantine」的子串,会把上下文里的词劈坏)。自己人的重复交给去重处理。
112
+ var text = conversationText()
113
+ var counts = {}, m
114
+ // 汉字词也收(2~8 个连续汉字算一个词):「文档」「变量」这类中文词不进池子,
115
+ // 光靠英文标识符就永远补不出来(指出)。
116
+ var re = /[A-Za-z_][A-Za-z0-9_]{3,}|[\u4e00-\u9fff]+/g
117
+ var CJK_STOP = { 我们: 1, 你们: 1, 他们: 1, 这个: 1, 那个: 1, 一个: 1, 什么: 1, 怎么: 1,
118
+ 因为: 1, 所以: 1, 然后: 1, 如果: 1, 就是: 1, 可以: 1, 没有: 1, 已经: 1,
119
+ 还是: 1, 但是: 1, 而且: 1, 现在: 1 }
120
+ var STOP = { this: 1, that: 1, with: 1, from: 1, your: 1, have: 1, will: 1, that: 1, there: 1, their: 1, what: 1,
121
+ when: 1, then: 1, than: 1, they: 1, been: 1, were: 1, void: 1, true: 1, false: 1, null: 1 }
122
+ while ((m = re.exec(text)) !== null) {
123
+ var w = m[0]
124
+ var low = w.toLowerCase()
125
+ if (STOP[low] || CJK_STOP[w]) continue
126
+ // 汉字串整个跳过:没有分词器时切出来的 n 字块会把整句贴回弹层;中文候选交给服务端词典。
127
+ if (/^[\u4e00-\u9fff]+$/.test(w)) continue
128
+ counts[w] = (counts[w] || 0) + 1
129
+ }
130
+ var words = Object.keys(counts).sort(function (a, b) { return counts[b] - counts[a] || (a < b ? -1 : 1) }).slice(0, 120)
131
+ ctxCache = { text: text, words: words, at: now }
132
+ return words
133
+ } catch (e) { return [] }
134
+ }
135
+
136
+ /** 候选选中行:可见的淡蓝底 + 左侧强调条(VS Code 的 activeSelection 观感)。 */
137
+ var SEL_STYLE = 'background:rgba(86,140,224,.22) !important;box-shadow:inset 2px 0 0 0 #4c8dff;'
138
+
139
+ /** 显示层折叠(一次反馈):
140
+ * harness/依赖目录里的文件不展开长路径,只显示 `dsh: <文件名>`;其它类型原样。
141
+ * 只影响**显示**,插进正文的仍是完整路径(it.value)。 */
142
+ var HARNESS_LABEL = /(deepseek[-_]harness|dsh-(fixes|kit|shim|020rc2|017)|node_modules|(^|\/)plugins\/)/i
143
+ function foldLabel(it) {
144
+ var v = String(it.value || '')
145
+ if (it.kind !== 'file' || !HARNESS_LABEL.test(v)) return v
146
+ var base = v.slice(v.lastIndexOf('/') + 1)
147
+ return 'dsh: ' + base
148
+ }
149
+
150
+ /* 标红画在**文字底下**(每一行的文字框底边),不是输入框底边:
151
+ * 输入框底边离文字约 27px,看起来像给整个框描一道线,和「这个词有问题」无关。
152
+ * 用固定定位的覆盖层逐行画,不动宿主 DOM(Lexical 会重建节点、也会把行内样式冲掉)。 */
153
+ var warnLayer = null, warnKey = ''
154
+ function ensureWarnLayer() {
155
+ if (warnLayer && warnLayer.parentNode) return warnLayer
156
+ var exist = document.getElementById('dce-quote-warn-layer')
157
+ if (exist) { warnLayer = exist; return exist }
158
+ var d = document.createElement('div')
159
+ d.id = 'dce-quote-warn-layer'
160
+ d.setAttribute('data-dsh-plugin', 'dsh-composer-ease')
161
+ d.setAttribute('aria-hidden', 'true')
162
+ d.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;pointer-events:none;z-index:2147479990'
163
+ document.body.appendChild(d)
164
+ warnLayer = d
165
+ return d
166
+ }
167
+ function paintQuoteWarn(ed, idx) {
168
+ try {
169
+ var layer = ensureWarnLayer()
170
+ if (idx === undefined || idx < 0) { if (layer.childNodes.length) layer.innerHTML = ''; warnKey = ''; return }
171
+ var box = composerOf(ed) || ed
172
+ // 只量**那一个字符**的盒子:一行里只有这个引号没闭合,画整行会让人以为整句都错了。
173
+ var rect = null
174
+ var walker = document.createTreeWalker(box, 4 /* SHOW_TEXT */, null)
175
+ var node, acc = 0
176
+ while ((node = walker.nextNode())) {
177
+ var len = (node.nodeValue || '').length
178
+ if (idx < acc + len) {
179
+ var r = document.createRange()
180
+ var at = idx - acc
181
+ r.setStart(node, at)
182
+ r.setEnd(node, Math.min(len, at + 1))
183
+ var rects = r.getClientRects()
184
+ if (rects && rects.length) rect = rects[0]
185
+ break
186
+ }
187
+ acc += len
188
+ }
189
+ if (!rect) { if (layer.childNodes.length) layer.innerHTML = ''; return }
190
+ var sig = Math.round(rect.left) + ',' + Math.round(rect.bottom) + ',' + Math.round(rect.width)
191
+ if (sig === warnKey) return
192
+ warnKey = sig
193
+ layer.innerHTML = ''
194
+ var wave = document.createElement('div')
195
+ wave.style.cssText = 'position:fixed;left:' + Math.round(rect.left) + 'px;top:' + Math.round(rect.bottom - 3) + 'px;' +
196
+ 'width:' + Math.max(6, Math.round(rect.width)) + 'px;height:4px;background-repeat:repeat-x;background-position:left top;' +
197
+ 'background-size:8px 4px;background-image:url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'8\' height=\'4\'><path d=\'M0 3 Q2 0 4 3 T8 3\' fill=\'none\' stroke=\'%23e5534b\' stroke-width=\'1.2\'/></svg>")'
198
+ layer.appendChild(wave)
199
+ } catch (e) {}
200
+ }
201
+ function renderPopup(anchor, force) {
202
+ var d = ensurePopup()
203
+ var sig = itemsSignature(popup.items) + '#' + (popup.wm ? popup.wm.text + popup.wm.angle : '')
204
+ if (!force && popup.open && sig === popup.sig) return // 列表没变就别重画:打字快时不再抖
205
+ popup.sig = sig
206
+ d.innerHTML = ''
207
+ // 语言水印(定的口径):不再在顶上写「据对话推断 · java」那种句子,
208
+ // 只把**语言词表的语言名**做成底纹压在右下角,斜排、透明;语言越多越斜,封顶 45°。
209
+ // 纯装饰:不吃点击、不进无障碍树,没有语言时整块不渲染。
210
+ if (popup.wm && popup.wm.text) {
211
+ var wm = document.createElement('div')
212
+ wm.setAttribute('data-dsh-cp-wm', '1')
213
+ wm.setAttribute('aria-hidden', 'true')
214
+ wm.style.cssText = 'position:absolute;right:6px;bottom:2px;pointer-events:none;user-select:none;' +
215
+ 'transform:rotate(-' + popup.wm.angle + 'deg);transform-origin:right bottom;' +
216
+ 'font-size:18px;font-weight:700;letter-spacing:.04em;line-height:1;white-space:nowrap;' +
217
+ 'opacity:.15;color:var(--dsw-alias-label-primary,currentColor)'
218
+ wm.textContent = popup.wm.text
219
+ d.appendChild(wm)
220
+ }
221
+ popup.items.forEach(function (it, i) {
222
+ var row = document.createElement('div')
223
+ var label = foldLabel(it)
224
+ var m = (it.matches && it.matches.length) ? it.matches : null
225
+ // 布局照 VS Code:左边类型图标、中间标签(命中字符加粗)、**右边**淡类型词——
226
+ // 类型信息一律在右侧,不会跑到候选名前面去(提的排版问题)。
227
+ row.innerHTML = '<span style="flex:0 0 auto;line-height:0">' + kindIcon(it) + '</span>' +
228
+ '<span style="flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' +
229
+ (m ? boldHtml(label, m) : escapeHtml(label.length > 60 ? label.slice(0, 57) + '…' : label)) + '</span>' +
230
+ '<span style="flex:0 0 auto;opacity:.4;font-size:11px;font-weight:400;margin-left:8px">' + escapeHtml(kindLabel(it)) + '</span>'
231
+ // 选中态自己给一套看得见的样式:主题的 interactive-bg-hover 实测是 rgba(38,49,72,.06),
232
+ // 深色背景下等于没有(上下键改了选中也看不出来)。
233
+ row.style.cssText = 'display:flex;align-items:center;gap:6px;padding:3px 10px;cursor:pointer;' +
234
+ (i === popup.index ? SEL_STYLE : '')
235
+ // 用**被点这一行**的下标接受:早先只读 popup.index(靠悬停更新),
236
+ // 没有 hover 时点任何一行都等于接受第一条(报「点击提示框没反应」)。
237
+ row.onmousedown = function (ev) { ev.preventDefault(); popup.index = i; acceptCurrent() }
238
+ // 悬停只换高亮:早先这里调 renderPopup 重建整层,鼠标一进去脚下的节点就被换掉,
239
+ // mousedown 落不到稳定节点 → 「点击提示框没反应」()。
240
+ row.onmouseenter = function () {
241
+ if (popup.index === i) return
242
+ popup.index = i
243
+ showGhost(anchor) // 悬停换选中,行内预览同步(与方向键同一口径)
244
+ var kids = d.children
245
+ for (var kk = 0; kk < kids.length; kk++) {
246
+ kids[kk].style.cssText = kids[kk].style.cssText.split(SEL_STYLE).join('') + (kk === i ? SEL_STYLE : '')
247
+ }
248
+ }
249
+ d.appendChild(row)
250
+ })
251
+ if (anchor) {
252
+ var caret = caretRect(anchor)
253
+ var vw = window.innerWidth, vh = window.innerHeight
254
+ // 宽度固定一档(不随候选长短抖),高度封顶,超出滚动 —— VSCode 建议框也是这个路子
255
+ var width = Math.round(Math.min(520, Math.max(300, vw * 0.36)))
256
+ var maxH = Math.round(Math.min(260, Math.max(140, vh * 0.3)))
257
+ d.style.width = width + 'px'
258
+ d.style.maxWidth = 'none'
259
+ d.style.maxHeight = maxH + 'px'
260
+ d.style.left = Math.max(8, Math.min(Math.round(caret.left), vw - width - 8)) + 'px'
261
+ var roomBelow = vh - Math.round(caret.bottom)
262
+ if (roomBelow >= maxH + 24) { // 下面放得下就放下边(VSCode:优先光标下方)
263
+ d.style.top = Math.round(caret.bottom + 6) + 'px'
264
+ d.style.bottom = 'auto'
265
+ } else { // 放不下才翻到上方,并贴住光标那一行
266
+ d.style.bottom = Math.max(8, vh - Math.round(caret.top) + 6) + 'px'
267
+ d.style.top = 'auto'
268
+ }
269
+ }
270
+ d.style.display = 'block'
271
+ popup.open = true
272
+ }
273
+ /* 行内提示(VSCode 的 inline suggestion 手感,原话「可以在前面添加类似 abc 的提示」):
274
+ * 首位候选比已打的前缀多出来的那截,用灰字贴在光标后面;不往宿主 DOM 里插节点,只浮一个 div。 */
275
+ var ghost = { el: null }
276
+ function ensureGhost() {
277
+ if (ghost.el) return ghost.el
278
+ var existG = document.getElementById('dsh-cp-ghost')
279
+ if (existG) { ghost.el = existG; return existG }
280
+ var d = document.createElement('div')
281
+ d.id = 'dsh-cp-ghost'
282
+ d.setAttribute('data-dsh-plugin', 'dsh-composer-ease')
283
+ d.style.cssText = 'position:fixed;z-index:2147479999;display:none;pointer-events:none;white-space:pre;' +
284
+ 'color:var(--dsw-alias-label-secondary,rgba(165,175,190,.8));opacity:.8'
285
+ document.body.appendChild(d)
286
+ ghost.el = d
287
+ return d
288
+ }
289
+ function hideGhost() { if (ghost.el) ghost.el.style.display = 'none' }
290
+ function showGhost(ed, token) {
291
+ if (!popup.open) return hideGhost() // 浮层已经收了就别再出灰字(回车后迟到的响应会走到这里)
292
+ try {
293
+ // **跟着选中项走**(一次反馈)。
294
+ // 早先固定取 items[0],所以上下键改了选中、行内灰字不动 —— VS Code 的 suggest.preview 是随选中变化的。
295
+ var it = popup.items[popup.index] || popup.items[0]
296
+ var v = it ? String(it.value || '') : ''
297
+ // token 缺省时从光标处现取:悬停/方向键这两条路手上没有 prefix 参数。
298
+ var t = String(token == null ? tokenBefore(textBeforeCaret(ed)) : token)
299
+ if (!t || !v || v.slice(0, t.length).toLowerCase() !== t.toLowerCase()) return hideGhost()
300
+ var rest = v.slice(t.length)
301
+ if (!rest || rest.length > 48) return hideGhost()
302
+ var d = ensureGhost()
303
+ var r = caretRect(ed)
304
+ d.textContent = rest
305
+ try { d.style.font = window.getComputedStyle(ed).font } catch (e) {}
306
+ d.style.left = Math.round(r.left) + 'px'
307
+ d.style.top = Math.round(r.top) + 'px'
308
+ d.style.display = 'block'
309
+ } catch (e) { hideGhost() }
310
+ }
311
+
312
+ /* 「弹层代数」:每次 closePopup 就 +1。
313
+ * 一次反馈——
314
+ * 真因是候选请求的 .then 在回车清场**之后**才回来,把浮层与灰字又画了一遍。
315
+ * 发请求那一刻记下代数,响应回来对不上就直接丢弃,别去猜"还能不能画"。 */
316
+ var popupEpoch = 0
317
+ function closePopup() { popupEpoch++; if (popup.root) popup.root.style.display = 'none'; popup.open = false; popup.items = []; popup.sig = ''; lastSelText = ''; hideGhost() }
318
+ var lastPrefix = null, inflight = 0
319
+
320
+ /** 草稿里已有的单词(VSCode word based suggestions 的口径:标识符形状、长度≥3、按出现次数排)。 */
@@ -0,0 +1,174 @@
1
+ // ── 模块:语言判定与候选拉取 ────────────────────────────────
2
+ // docWords、语言投票与语言水印、fetchCandidates(本地词先上屏、网络回来再合并)。
3
+ // 本文件是 lib/client.js 的片段,由 tools/build-client.mjs 按序拼回;不要直接改生成物 lib/client.js。
4
+
5
+ function docWords(text, token) {
6
+ var t = String(token || '').toLowerCase()
7
+ if (!t) return []
8
+ var counts = {}, m
9
+ // 英文标识符 + 连续汉字词(2~8 字)都收;普通长串再补一个 2 字前缀,
10
+ // 这样「文档里的变量」里也能提出「文档」(要的「像提示变量那样」)。
11
+ var re = /[A-Za-z_][A-Za-z0-9_]{2,}|[\u4e00-\u9fff]+/g
12
+ var CJKONLY = /^[\u4e00-\u9fff]+$/
13
+ while ((m = re.exec(String(text || ''))) !== null) {
14
+ var w = m[0]
15
+ if (w.toLowerCase() === t) continue
16
+ // 汉字串不在这里切片:客户端没有分词器,切出来的 n 字块既可能是句子片段,也得靠一个
17
+ // 写死的长度上限兜着。中文候选一律交给服务端的词典(lexiconCandidates),边界由数据定。
18
+ if (CJKONLY.test(w)) continue
19
+ counts[w] = (counts[w] || 0) + 1
20
+ }
21
+ return Object.keys(counts)
22
+ .filter(function (w) { return w.toLowerCase().indexOf(t) === 0 })
23
+ .sort(function (a, b) { return counts[b] - counts[a] || a.length - b.length || (a < b ? -1 : 1) })
24
+ .slice(0, 5)
25
+ .map(function (w) {
26
+ var pos = []
27
+ for (var i = 0; i < t.length; i++) pos.push(i) // 前缀命中:把已打的那几个字符加粗
28
+ return { kind: 'word', value: w, detail: '本文', src: 'composer', matches: pos }
29
+ })
30
+ }
31
+
32
+ /** 草稿里最近一个围栏标记(```js / ```python)当语言提示;没有就交给服务端上公共词表。 */
33
+ function langHint(text) {
34
+ var m = String(text || '').match(/```([A-Za-z0-9+#._-]{1,12})/g)
35
+ return m && m.length ? m[m.length - 1].slice(3) : ''
36
+ }
37
+
38
+ function composerText(ed) {
39
+ try { return isText(ed) ? ed.value : String((composerOf(ed) || ed).innerText || '') } catch (e) { return '' }
40
+ }
41
+
42
+ /** 语言投票:返回语言 + 票数 + 命中明细。
43
+ * 同一份投票既决定「上哪张词表」,也决定弹层顶上那行出处(
44
+ * 「编程语言要和上下文有关联、并让人看见依据」)。票数低于 2 一律判不定。 */
45
+ var LANG_RULES = [
46
+ ['python', /\b(def|import|from|elif|lambda|None|True|False|self)\b/g, 1],
47
+ ['python', /\.py\b|\bpip\b|\bpytest\b|\bvenv\b/g, 2],
48
+ ['javascript', /\b(const|let|var|function|=>|async|await|module\.exports|require)\b/g, 1],
49
+ ['javascript', /\.(js|mjs|ts|tsx|json)\b|\bnpm\b|\bnode\b/g, 2],
50
+ ['html', /<\/?[a-z][a-z0-9-]*\b|\bclass=|\bid=|<\/div>|\b<!doctype\b/gi, 2],
51
+ ['css', /\b(color|display|margin|padding|font-size|background)\s*:|\{[^}]*:[^}]*\}/g, 1],
52
+ ['java', /\b(public|private|protected|void|static|implements|extends|new|String)\b|@Override|System\.out|String\[\]/g, 1]
53
+ ]
54
+ function inferLangVote(text) {
55
+ var t = String(text || '')
56
+ if (!t) return { lang: '', top: 0, hits: {} }
57
+ var score = {}, hits = {}
58
+ for (var i = 0; i < LANG_RULES.length; i++) {
59
+ var m = t.match(LANG_RULES[i][1])
60
+ if (!m) continue
61
+ score[LANG_RULES[i][0]] = (score[LANG_RULES[i][0]] || 0) + m.length * LANG_RULES[i][2]
62
+ hits[LANG_RULES[i][0]] = (hits[LANG_RULES[i][0]] || 0) + m.length
63
+ }
64
+ var best = '', top = 0, ranking = []
65
+ for (var k in score) {
66
+ if (score[k] > top) { top = score[k]; best = k }
67
+ if (score[k] >= 2) ranking.push({ lang: k, score: score[k] })
68
+ }
69
+ ranking.sort(function (a, b) { return b.score - a.score || (a.lang < b.lang ? -1 : 1) })
70
+ return { lang: top >= 2 ? best : '', top: top, hits: hits, ranking: ranking }
71
+ }
72
+
73
+ /** 语言水印(口径):只写**语言名**,不写「据对话推断」这类句子;
74
+ * 斜角随语言种数增长、封顶 45°(1→14°、2→26°、3→38°、4 及以上→45°)。没有语言就返回 null。 */
75
+ function watermarkFor(langs) {
76
+ var list = (langs || []).filter(Boolean).slice(0, 4)
77
+ if (!list.length) return null
78
+ return { text: list.join(' · '), angle: Math.min(45, 14 + 12 * (list.length - 1)) }
79
+ }
80
+
81
+ /** 从**上下文文本**(草稿 + 对话正文)推语言:围栏之外的第二条腿。 */
82
+ function inferLang(text) { return inferLangVote(text).lang }
83
+
84
+ function fetchCandidates(prefix, ed) {
85
+ var draft = composerText(ed)
86
+ var prefixList = [String(prefix || '')]
87
+ if (/^[\u4e00-\u9fff]+$/.test(prefixList[0])) {
88
+ for (var pl2 = 4; pl2 >= 2; pl2--) if (prefixList[0].length >= pl2) prefixList.push(prefixList[0].slice(-pl2))
89
+ }
90
+ var local = [], seenDraft = {}
91
+ for (var di = 0; di < prefixList.length; di++) {
92
+ var batch = docWords(draft, prefixList[di])
93
+ for (var bi = 0; bi < batch.length; bi++) {
94
+ var key = batch[bi].value.toLowerCase()
95
+ if (seenDraft[key]) continue
96
+ seenDraft[key] = 1
97
+ local.push(batch[bi])
98
+ }
99
+ if (local.length >= 6) break // 短前缀够用了就别再往更短的找
100
+ }
101
+ var fenced = langHint(draft)
102
+ if (fenced) habitBump(fenced, 1)
103
+ var ctxWords = contextWords(ed)
104
+ // 投票用**对话原文**(不是去重后的词表):词表是集合,`System.out` / `@Override` 这种
105
+ // 带标点的语言指纹在切词时就被劈掉了,而且词频信息也丢了。原文截尾 4000 字够用。
106
+ var ctxRaw = String(ctxCache.text || '').slice(-4000)
107
+ // 语言优先级:围栏 → 上下文推断(草稿 + 对话正文)→ 习惯
108
+ var vote = inferLangVote(draft + ' ' + ctxRaw)
109
+ var lang = fenced || vote.lang || habitTop()
110
+ // 语言水印(替代原来顶上那行「据对话推断 · java」):只写语言名,斜排压在右下角。
111
+ // 围栏时只报围栏那一个语言;否则报投票过线的语言,按票数从高到低,最多 4 个。
112
+ var wmLangs = fenced ? [fenced]
113
+ : (vote.ranking && vote.ranking.length ? vote.ranking.map(function (r) { return r.lang })
114
+ : (lang ? [lang] : []))
115
+ popup.wm = watermarkFor(wmLangs)
116
+ // 上下文候选:对话里出现过的标识符按前缀补出来(跟上下文强关联的那一路)
117
+ // 中文没有分词:整段汉字会被当成一个 token,所以按「1~4 字后缀」各试一次(长的优先),
118
+ // 打「文」也能命中草稿里更早出现的「文档」(原话要求)。
119
+ var p0 = String(prefix || '')
120
+ var pfxList = [p0]
121
+ if (/^[\u4e00-\u9fff]+$/.test(p0)) {
122
+ for (var pl = 4; pl >= 2; pl--) if (p0.length >= pl) pfxList.push(p0.slice(-pl))
123
+ }
124
+ var ctxItems = []
125
+ var seenLocal = {}
126
+ local.forEach(function (i) { seenLocal[i.value.toLowerCase()] = 1 })
127
+ ctxWords.forEach(function (w) {
128
+ if (ctxItems.length >= 5) return
129
+ var lw = w.toLowerCase()
130
+ if (seenLocal[lw]) return
131
+ for (var qi = 0; qi < pfxList.length; qi++) {
132
+ var q = pfxList[qi].toLowerCase()
133
+ if (!q || lw === q || lw.indexOf(q) !== 0) continue
134
+ seenLocal[lw] = 1
135
+ var pos = []
136
+ for (var k = 0; k < q.length; k++) pos.push(k)
137
+ ctxItems.push({ kind: 'word', value: w, detail: '上下文', src: 'context', matches: pos })
138
+ return
139
+ }
140
+ })
141
+ // ctx words lead only for prefix >= 3 (or CJK): a 2-char prefix must not be hijacked
142
+ // by a word that merely appeared in the draft (measured: de led with DeepSeek).
143
+ var ctxLead = /^[\u4e00-\u9fff]/.test(String(prefix || '')) || String(prefix || '').length >= 3
144
+ var first = ctxLead ? local.concat(ctxItems) : local.slice()
145
+ var cjkPrefix = /^[\u4e00-\u9fff]/.test(String(prefix || ''))
146
+ // 中文要等服务端的「完整词义」闸:碎片连本地词都不许先上屏(否则会闪一下又不算数)
147
+ if (first.length && !cjkPrefix) { popup.items = first; popup.index = 0; renderPopup(ed) }
148
+ inflight++
149
+ var seq = inflight
150
+ var ep = popupEpoch // 发请求时的代数:期间被关掉(回车发送/失焦/碎片闸)就作废这次响应
151
+ var draftTail = composerText(ed).slice(-800)
152
+ fetch(ROUTE_WORDS + '?prefix=' + encodeURIComponent(prefix || '') + '&lang=' + encodeURIComponent(lang) +
153
+ '&draft=' + encodeURIComponent(draftTail))
154
+ .then(function (r) { return r.json() })
155
+ .then(function (d) {
156
+ if (seq !== inflight) return // 后发先至的旧响应丢掉(补全弹层最怕错位)
157
+ if (ep !== popupEpoch) return // 迟到的响应不许把已经收起来的浮层/灰字复活
158
+ // 服务端判定这不是完整词义(碎片)→ 整条收起,连本地/上下文候选都不出
159
+ if (d && d.wordGate === false) { closePopup(); return }
160
+ var items = local.slice(), seen = {}
161
+ local.forEach(function (i) { seen[i.value] = 1 })
162
+ // 同一规则作用在 fetch 返回后的最终合并上(首屏那次只是占位):短前缀下 ctx 词
163
+ // 排在服务端候选**之后**,否则 DeepSeek 这类草稿词还是会顶掉 def/delete。
164
+ var ctxList = ctxLead ? ctxItems : []
165
+ ctxList.forEach(function (c) { if (!seen[c.value]) { seen[c.value] = 1; items.push(c) } })
166
+ if (d && d.ok === true) (d.candidates || []).forEach(function (c) { if (c && !seen[c.value]) { seen[c.value] = 1; items.push(c) } })
167
+ if (!ctxLead) ctxItems.forEach(function (c) { if (!seen[c.value]) { seen[c.value] = 1; items.push(c) } })
168
+ // 类型开关:在**合并这一处**滤掉,后面的索引 / 灰字 / Tab 都跟着同一份列表
169
+ items = items.filter(function (x) { return isKindOn(x.kind) })
170
+ if (!items.length) { closePopup(); return }
171
+ popup.items = items.slice(0, 12); popup.index = 0; renderPopup(ed); showGhost(ed, prefix)
172
+ })
173
+ .catch(function () { if (ep === popupEpoch && !first.length) closePopup() })
174
+ }