dsh-code-server-app 0.3.59 → 0.3.63
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-Apache-2.0.txt +201 -0
- package/README.en.md +93 -9
- package/README.md +69 -7
- package/THIRD_PARTY_NOTICES.md +60 -0
- package/assets/extensions/dshcs-editor-bridge/extension.js +149 -2
- package/assets/extensions/dshcs-editor-bridge/lib/bridge-client.js +38 -2
- package/assets/extensions/dshcs-editor-bridge/lib/fim-completion.js +264 -0
- package/assets/extensions/dshcs-editor-bridge/package.json +2 -2
- package/lib/bridge.mjs +6 -3
- package/lib/client.js +139 -3
- package/lib/fim-adapter.mjs +603 -0
- package/lib/index.js +294 -1
- package/package.json +17 -14
- package/vendor/VENDOR.json +5 -5
|
@@ -38,6 +38,17 @@ const {
|
|
|
38
38
|
createClient,
|
|
39
39
|
} = require('./lib/bridge-client.js');
|
|
40
40
|
const { createProjector } = require('./lib/context-model.js');
|
|
41
|
+
const {
|
|
42
|
+
FIM_DEBOUNCE_DEFAULT_MS,
|
|
43
|
+
shouldRequest,
|
|
44
|
+
buildPayload,
|
|
45
|
+
fingerprint,
|
|
46
|
+
createCompletionCache,
|
|
47
|
+
formatTokens,
|
|
48
|
+
describeFimUsage,
|
|
49
|
+
matchDisabledGlob,
|
|
50
|
+
toSingleLine,
|
|
51
|
+
} = require('./lib/fim-completion.js');
|
|
41
52
|
const { chooseOldSide, createDiffCache, describeChange } = require('./lib/diff-model.js');
|
|
42
53
|
|
|
43
54
|
/** 状态栏项(仅在桥连通时显示)。 */
|
|
@@ -65,6 +76,119 @@ const diffTextStore = new Map();
|
|
|
65
76
|
/** 同一个文件的 diff tab 不重复开。 */
|
|
66
77
|
const openDiffTabs = new Map();
|
|
67
78
|
|
|
79
|
+
// ---------------------------------------------------------------- FIM(幽灵)补全:实验性
|
|
80
|
+
//
|
|
81
|
+
// 这条能力**只在宿主说 enabled=true 时才注册 provider**(宿主的 /sync 每趟都带上来),
|
|
82
|
+
// 所以"设置里没开"不会让编辑器每敲一个字就发一次注定被拒的请求。
|
|
83
|
+
//
|
|
84
|
+
// 三条与宿主一致的分工:
|
|
85
|
+
// - **该不该问**在这里判(纯逻辑 shouldRequest):不值得问的位置根本不发请求 ——
|
|
86
|
+
// 实测模型在被问到"不该补的地方"时会硬凑(2/2),所以判据必须前置;
|
|
87
|
+
// - **问什么**在这里裁(窗口内的前后文),宿主侧再兜一层同样的上限;
|
|
88
|
+
// - **问过没有**用本地缓存答(相同前后文 + 语言)。
|
|
89
|
+
/** 宿主报来的 FIM 状态与用量({enabled:false} 表示设置里没开)。 */
|
|
90
|
+
let fimSnapshot = { enabled: false };
|
|
91
|
+
/** 已注册的 provider disposer(null = 未注册)。 */
|
|
92
|
+
let fimProvider = null;
|
|
93
|
+
/** 扩展上下文(activate 里赋值):provider 需要在轮询里注册/注销,而轮询拿不到 context 参数。 */
|
|
94
|
+
let extensionContext = null;
|
|
95
|
+
/** 前后文 → 补全文本 的有界缓存。 */
|
|
96
|
+
const fimCache = createCompletionCache();
|
|
97
|
+
|
|
98
|
+
/** 取消感知的停顿:打字过程中的请求全丢掉(宿主侧也有速率闸,但那是最后一道)。
|
|
99
|
+
* 停顿毫秒数由设置在宿主侧提供(0.3.62),每趟 /sync 带上来;拿不到就用默认值。 */
|
|
100
|
+
async function fimWait(token) {
|
|
101
|
+
const configured = Number.isFinite(fimSnapshot.debounceMs) ? fimSnapshot.debounceMs : FIM_DEBOUNCE_DEFAULT_MS;
|
|
102
|
+
const deadline = Date.now() + configured;
|
|
103
|
+
while (Date.now() < deadline) {
|
|
104
|
+
if (token.isCancellationRequested === true) return false;
|
|
105
|
+
// 40ms 一跳:足够细,且不会为一次补全拉起一串定时器。
|
|
106
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
107
|
+
}
|
|
108
|
+
return token.isCancellationRequested !== true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 取光标前后的窗口(只按行取,不把整份文档摸一遍 —— 大文件上每次击键都 getText() 是灾难)。 */
|
|
112
|
+
function fimWindows(document, position) {
|
|
113
|
+
const startLine = Math.max(0, position.line - 120);
|
|
114
|
+
const endLine = Math.min(document.lineCount - 1, position.line + 40);
|
|
115
|
+
const prefixText = document.getText(new vscode.Range(new vscode.Position(startLine, 0), position));
|
|
116
|
+
const afterEnd = document.lineAt(endLine).range.end;
|
|
117
|
+
const suffixText = document.getText(new vscode.Range(position, afterEnd));
|
|
118
|
+
const lineTextBefore = document.lineAt(position.line).text.slice(0, position.character);
|
|
119
|
+
return { prefixText, suffixText, lineTextBefore };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 内联补全 provider。返回 null = 这次不补(与"补一个空串"是两回事)。 */
|
|
123
|
+
function createFimProvider() {
|
|
124
|
+
return {
|
|
125
|
+
async provideInlineCompletionItems(document, position, _context, token) {
|
|
126
|
+
if (fimSnapshot.enabled !== true) return null;
|
|
127
|
+
const editor = vscode.window.activeTextEditor;
|
|
128
|
+
const windows = fimWindows(document, position);
|
|
129
|
+
const decision = shouldRequest({
|
|
130
|
+
selectionEmpty: editor === undefined || editor === null ? true : editor.selection.isEmpty,
|
|
131
|
+
scheme: document.uri.scheme,
|
|
132
|
+
lineCount: document.lineCount,
|
|
133
|
+
prefixText: windows.prefixText,
|
|
134
|
+
suffixText: windows.suffixText,
|
|
135
|
+
lineTextBefore: windows.lineTextBefore,
|
|
136
|
+
});
|
|
137
|
+
if (decision.ask !== true) return null;
|
|
138
|
+
const payload = buildPayload({
|
|
139
|
+
prefixText: windows.prefixText,
|
|
140
|
+
suffixText: windows.suffixText,
|
|
141
|
+
language: document.languageId,
|
|
142
|
+
path: document.uri.scheme === 'file' ? document.uri.fsPath : document.uri.toString(),
|
|
143
|
+
});
|
|
144
|
+
// 按 glob 禁用(0.3.62):**在发请求之前**判 —— 这是这一层存在的主要理由(不花冤枉钱)。
|
|
145
|
+
// 宿主侧还会再判一次(服务端那道),所以即使这里漏了也不会真的发出去。
|
|
146
|
+
const blocked = matchDisabledGlob(payload.path, fimSnapshot.disableGlobs);
|
|
147
|
+
if (blocked !== null) return null;
|
|
148
|
+
const key = fingerprint(payload.prompt, payload.suffix, payload.language);
|
|
149
|
+
const cached = fimCache.get(key);
|
|
150
|
+
if (cached !== null) {
|
|
151
|
+
return cached === '' ? null : [new vscode.InlineCompletionItem(cached, new vscode.Range(position, position))];
|
|
152
|
+
}
|
|
153
|
+
if ((await fimWait(token)) !== true) return null;
|
|
154
|
+
if (client === null || client.isDormant()) return null;
|
|
155
|
+
if (typeof client.complete !== 'function') return null;
|
|
156
|
+
const result = await client.complete(payload);
|
|
157
|
+
if (token.isCancellationRequested === true) return null;
|
|
158
|
+
if (result.ok !== true) {
|
|
159
|
+
if (result.status !== 409 && result.status !== 429) log(`补全失败:${result.error ?? '未知'}(status=${result.status ?? 0})`);
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
// 多行关掉时宿主已经裁过一道;这里再裁一次是防"宿主版本比扩展旧"(随包分发的静态文件可能不同步)。
|
|
163
|
+
const raw = typeof result.text === 'string' ? result.text : '';
|
|
164
|
+
const text = fimSnapshot.multiline === false ? toSingleLine(raw) : raw;
|
|
165
|
+
fimCache.set(key, text);
|
|
166
|
+
if (text.trim() === '') return null; // 模型说"这里不用补"是合法回答
|
|
167
|
+
log(`补全 ${text.length} 字符(${result.ms ?? '?'}ms,缓存${result.usage?.cacheReadTokens ?? 0} tok)`);
|
|
168
|
+
return [new vscode.InlineCompletionItem(text, new vscode.Range(position, position))];
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 按宿主状态注册/注销 provider(状态翻转时才动)。 */
|
|
174
|
+
function syncFimProvider() {
|
|
175
|
+
const want = fimSnapshot.enabled === true;
|
|
176
|
+
if (want && fimProvider === null) {
|
|
177
|
+
fimProvider = vscode.languages.registerInlineCompletionItemProvider({ pattern: '**' }, createFimProvider());
|
|
178
|
+
if (extensionContext !== null) extensionContext.subscriptions.push(fimProvider);
|
|
179
|
+
log('FIM 补全(实验性)已启用:开始提供内联补全');
|
|
180
|
+
} else if (!want && fimProvider !== null) {
|
|
181
|
+
try {
|
|
182
|
+
fimProvider.dispose();
|
|
183
|
+
} catch {
|
|
184
|
+
// 已被 VS Code 回收
|
|
185
|
+
}
|
|
186
|
+
fimProvider = null;
|
|
187
|
+
fimCache.clear();
|
|
188
|
+
log('FIM 补全已关闭:不再提供内联补全');
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
68
192
|
/** 把"改动前的文本"登记进 store,返回它的 docId。 */
|
|
69
193
|
function registerDiffText(text) {
|
|
70
194
|
const docId = crypto.createHash('sha256').update(text ?? '').digest('hex');
|
|
@@ -404,6 +528,13 @@ async function pollOnce() {
|
|
|
404
528
|
await handleEvent(event);
|
|
405
529
|
}
|
|
406
530
|
if ((result.events ?? []).length > 0) client.persist();
|
|
531
|
+
// FIM(实验性):宿主每趟带状态与用量上来。只在**翻转**时注册/注销 provider,
|
|
532
|
+
// 但状态栏每趟都刷 —— 用户要看到的是"这轮补全花了多少 token",它一直在变。
|
|
533
|
+
const nextFim = result.fim !== null && typeof result.fim === 'object' ? result.fim : { enabled: false };
|
|
534
|
+
const wasEnabled = fimSnapshot.enabled === true;
|
|
535
|
+
fimSnapshot = nextFim;
|
|
536
|
+
if ((nextFim.enabled === true) !== wasEnabled) syncFimProvider();
|
|
537
|
+
updateStatusBar();
|
|
407
538
|
// 能力探测(0.3.24):宿主证明"DSH 页面里的对话框活着"⇒ 右键提问走 ask-open 事件。
|
|
408
539
|
// 0.3.59 起这是**唯一**的提问 UI(编辑器里的 webview 面板已退役):探测不到就只弹一条提示,
|
|
409
540
|
// 绝不退回到一扇用户看不见的窗。
|
|
@@ -430,8 +561,23 @@ function startPolling(context) {
|
|
|
430
561
|
function updateStatusBar() {
|
|
431
562
|
if (statusBar === null) return;
|
|
432
563
|
if (connected && client !== null && client.config !== null) {
|
|
433
|
-
|
|
434
|
-
|
|
564
|
+
// FIM(实验性)开着时,状态栏多一格 token 计数 —— 这条链路的调用**不进 DSH 的计量**
|
|
565
|
+
// (一次性调用不是 loop 请求,不进会话日志 ⇒ token-meter 看不到它),所以这里是用户唯一能看到
|
|
566
|
+
// "补全花了多少"的地方(设置卡里也有同样一份,见 README)。
|
|
567
|
+
const fimOn = fimSnapshot !== null && fimSnapshot.enabled === true;
|
|
568
|
+
const used = fimOn ? Number(fimSnapshot.totalTokens ?? 0) : 0;
|
|
569
|
+
statusBar.text = fimOn
|
|
570
|
+
? (used > 0 ? `$(plug) DSH $(zap) ${formatTokens(used)}` : '$(plug) DSH $(zap)')
|
|
571
|
+
: '$(plug) DSH';
|
|
572
|
+
const lines = [
|
|
573
|
+
`编辑器桥已连接:${client.config.pipe}`,
|
|
574
|
+
`上次轮询:${lastPollAt === 0 ? '—' : new Date(lastPollAt).toLocaleTimeString()}`,
|
|
575
|
+
'',
|
|
576
|
+
...describeFimUsage(fimSnapshot),
|
|
577
|
+
'',
|
|
578
|
+
'点击查看日志',
|
|
579
|
+
];
|
|
580
|
+
statusBar.tooltip = lines.join('\n');
|
|
435
581
|
statusBar.command = 'dsh-code-server.showBridgeLog';
|
|
436
582
|
statusBar.show();
|
|
437
583
|
} else {
|
|
@@ -523,6 +669,7 @@ function showBridgeLog() {
|
|
|
523
669
|
// ---------------------------------------------------------------- 激活
|
|
524
670
|
|
|
525
671
|
function activate(context) {
|
|
672
|
+
extensionContext = context;
|
|
526
673
|
output = vscode.window.createOutputChannel('DSH Editor Bridge');
|
|
527
674
|
context.subscriptions.push(output);
|
|
528
675
|
|
|
@@ -35,6 +35,9 @@ const STATE_FILENAME = 'extension-state.json';
|
|
|
35
35
|
// 请求超时(0.2.8 起 8s):3s 在实测里太紧 —— 对话流快照一大,宿主序列化就要几秒,
|
|
36
36
|
// 超时会让扩展**丢整份响应**(包括 approvals),表现就是"授权卡片一直不出现"。
|
|
37
37
|
const REQUEST_TIMEOUT_MS = 8000;
|
|
38
|
+
/** 补全请求的超时:**独立且更短**。宿主侧自己在 4s 放弃,这里给 6s 只是别先于宿主超时 ——
|
|
39
|
+
* 补全的价值在"比你打字快",8s 那个值对它毫无意义,而挂住的请求会挡住下一次补全。 */
|
|
40
|
+
const COMPLETE_TIMEOUT_MS = 6000;
|
|
38
41
|
/** 轮询间隔:host 侧事件只是"去看一眼这个文件"的提示,600ms 足够且几乎无开销。 */
|
|
39
42
|
const POLL_INTERVAL_MS = 600;
|
|
40
43
|
/** 宿主配置重读间隔(端点/令牌轮换后最多这么久恢复)。 */
|
|
@@ -292,6 +295,9 @@ function createClient(options) {
|
|
|
292
295
|
ok: true,
|
|
293
296
|
events,
|
|
294
297
|
askDialog: body !== null && body.askDialog === true,
|
|
298
|
+
// FIM(实验性)状态与用量:宿主每趟都带上来,扩展据此决定"要不要注册补全 provider"
|
|
299
|
+
// 以及状态栏上那串数字怎么显示。没开时是 {enabled:false}(不是 null)。
|
|
300
|
+
fim: body !== null && typeof body.fim === 'object' && body.fim !== null ? body.fim : { enabled: false },
|
|
295
301
|
};
|
|
296
302
|
} catch (error) {
|
|
297
303
|
return { ok: false, error: error.message, status: error.status, code: error.code };
|
|
@@ -328,11 +334,40 @@ function createClient(options) {
|
|
|
328
334
|
return null;
|
|
329
335
|
}
|
|
330
336
|
},
|
|
337
|
+
/**
|
|
338
|
+
* 求一次 FIM(幽灵)补全(实验性)。
|
|
339
|
+
*
|
|
340
|
+
* **不抛**:补全失败是常态(没开、太快、模型抽风),抛异常会让 provider 每次都写错误日志。
|
|
341
|
+
* 返回值统一是 `{ok, text?, usage?, ms?, error?, status?}` —— 调用方只看 `ok`。
|
|
342
|
+
* 超时是独立的短超时(宿主侧 4s 就放弃了,这里给到 6s 只是别先于宿主超时)。
|
|
343
|
+
*/
|
|
344
|
+
async complete(payload) {
|
|
345
|
+
try {
|
|
346
|
+
const body = await requestImpl({
|
|
347
|
+
socketPath: (refreshConfig(true) ?? {}).pipe,
|
|
348
|
+
path: `${BRIDGE_BASE}/complete`,
|
|
349
|
+
method: 'POST',
|
|
350
|
+
headers: { [TOKEN_HEADER]: config === null ? '' : config.token, 'content-type': 'application/json' },
|
|
351
|
+
body: JSON.stringify(payload),
|
|
352
|
+
timeoutMs: COMPLETE_TIMEOUT_MS,
|
|
353
|
+
});
|
|
354
|
+
if (body === null || typeof body !== 'object') return { ok: false, error: '空响应' };
|
|
355
|
+
const status = body.status === undefined ? 0 : body.status;
|
|
356
|
+
if (status < 200 || status >= 300) {
|
|
357
|
+
const reason = body.json !== null && typeof body.json.error === 'string' ? body.json.error : `HTTP ${status}`;
|
|
358
|
+
return { ok: false, error: reason, status };
|
|
359
|
+
}
|
|
360
|
+
const json = body.json === null || typeof body.json !== 'object' ? {} : body.json;
|
|
361
|
+
if (json.ok !== true) return { ok: false, error: typeof json.error === 'string' ? json.error : '宿主未返回补全', status };
|
|
362
|
+
return { ok: true, text: typeof json.text === 'string' ? json.text : '', usage: json.usage ?? null, ms: json.ms ?? null };
|
|
363
|
+
} catch (error) {
|
|
364
|
+
return { ok: false, error: error && error.message ? error.message : String(error), status: error && error.status };
|
|
365
|
+
}
|
|
366
|
+
},
|
|
331
367
|
/** 把游标落盘(实例重启后不重复播报旧事件)。 */
|
|
332
368
|
persist() {
|
|
333
369
|
return writeState(extensionsDir, { since, pid: config === null ? null : config.pid });
|
|
334
|
-
},
|
|
335
|
-
/** 从磁盘恢复游标(实例没换才生效)。 */
|
|
370
|
+
}, /** 从磁盘恢复游标(实例没换才生效)。 */
|
|
336
371
|
restore() {
|
|
337
372
|
const saved = readState(extensionsDir);
|
|
338
373
|
if (config !== null && saved.pid === config.pid) since = saved.since;
|
|
@@ -351,6 +386,7 @@ module.exports = {
|
|
|
351
386
|
POLL_INTERVAL_MS,
|
|
352
387
|
CONFIG_REREAD_MS,
|
|
353
388
|
REQUEST_TIMEOUT_MS,
|
|
389
|
+
COMPLETE_TIMEOUT_MS,
|
|
354
390
|
BridgeError,
|
|
355
391
|
isBridgeEndpoint,
|
|
356
392
|
defaultExtensionsDir,
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// dshcs-editor-bridge / lib/fim-completion.js —— 纯逻辑:FIM 补全的请求决策与载荷构造
|
|
2
|
+
//
|
|
3
|
+
// 来源与许可:本文件**为独立实现,不含 continuedev/continue 的源码**;其门控/窗口/缓存的设计与
|
|
4
|
+
// 设置项划分**参考**了该项目(Apache License 2.0, Copyright 2023 Continue)。详见 `THIRD_PARTY_NOTICES.md`。
|
|
5
|
+
//
|
|
6
|
+
// **不 require('vscode')**:入参都是纯文本/数字,所以能在没有 VS Code 的环境里单测
|
|
7
|
+
// (scripts/test-fim.mjs 就是这么用的)。与编辑器交互的部分在 extension.js(glue)里。
|
|
8
|
+
//
|
|
9
|
+
// 这个文件回答三个问题(顺序就是补全一次要过的三关):
|
|
10
|
+
// 1. **该不该问**(`shouldRequest`)—— 模型在被问到"不该补的地方"时会硬凑(实测 2/2),
|
|
11
|
+
// 所以"不值得问"必须在**发请求之前**判掉,而不是拿到回答再丢;
|
|
12
|
+
// 2. **问什么**(`buildPayload`)—— 只送光标附近的窗口,不是整份文件;
|
|
13
|
+
// 3. **问过没有**(`createCompletionCache`)—— 同样的前后文重复问是纯浪费(实测 temperature=0
|
|
14
|
+
// 下两次回答逐字相同 ⇒ 缓存命中率会很高)。
|
|
15
|
+
//
|
|
16
|
+
// 窗口常量与宿主侧 `lib/fim-adapter.mjs` **同名同值**:扩展是随包分发的静态文件、不能 import
|
|
17
|
+
// 宿主代码,所以一致性靠 scripts/test-fim.mjs 的断言钉住(与 bridge-client.js 的桥常量同一套做法)。
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
/** 与宿主 lib/fim-adapter.mjs 保持一致(改一边就要改另一边,测试会拦)。 */
|
|
22
|
+
const FIM_MAX_PREFIX_CHARS = 6000;
|
|
23
|
+
const FIM_MAX_SUFFIX_CHARS = 2000;
|
|
24
|
+
const FIM_MAX_PREFIX_LINES = 120;
|
|
25
|
+
const FIM_MAX_SUFFIX_LINES = 40;
|
|
26
|
+
|
|
27
|
+
/** 停顿多久才真的发请求(毫秒)。打字过程中的请求全是浪费,而且会被宿主的速率闸挡掉。 */
|
|
28
|
+
const FIM_DEBOUNCE_MS = 250;
|
|
29
|
+
|
|
30
|
+
/** 超过这个行数的文档不参与补全(大文件里补全的收益低;用行数而不是字符数是为了避免
|
|
31
|
+
* 每次击键都 `document.getText()` 把整份文档摸一遍)。 */
|
|
32
|
+
const FIM_MAX_DOCUMENT_LINES = 20000;
|
|
33
|
+
|
|
34
|
+
/** 缓存条目上限与有效期(键 = 前后文指纹;命中直接给结果,不再问模型)。 */
|
|
35
|
+
const FIM_CACHE_MAX = 24;
|
|
36
|
+
const FIM_CACHE_TTL_MS = 120000;
|
|
37
|
+
|
|
38
|
+
/** 把文本裁到窗口内(与宿主侧 `trimWindow` 同语义:字符与行数取先到者)。 */
|
|
39
|
+
function trimWindow(text, maxChars, maxLines, fromEnd) {
|
|
40
|
+
if (typeof text !== 'string' || text === '') return '';
|
|
41
|
+
let out = text;
|
|
42
|
+
if (maxLines > 0) {
|
|
43
|
+
const split = out.split('\n');
|
|
44
|
+
if (split.length > maxLines) out = (fromEnd ? split.slice(split.length - maxLines) : split.slice(0, maxLines)).join('\n');
|
|
45
|
+
}
|
|
46
|
+
if (maxChars > 0 && out.length > maxChars) out = fromEnd ? out.slice(out.length - maxChars) : out.slice(0, maxChars);
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 该不该为这次光标位置请求补全。
|
|
52
|
+
*
|
|
53
|
+
* 判据都是"**便宜且不会错判**"的那些 —— 宁可不补,也不要补错(补错一次,用户就关掉它)。
|
|
54
|
+
*
|
|
55
|
+
* @param {{selectionEmpty: boolean, scheme: string, lineCount: number,
|
|
56
|
+
* prefixText: string, suffixText: string, lineTextBefore: string}} input
|
|
57
|
+
* @returns {{ask: boolean, reason: string}}
|
|
58
|
+
*/
|
|
59
|
+
function shouldRequest(input) {
|
|
60
|
+
const value = input === null || input === undefined ? {} : input;
|
|
61
|
+
if (value.selectionEmpty !== true) return { ask: false, reason: '有选区(用户在选中内容,不是在打字)' };
|
|
62
|
+
if (value.scheme !== 'file') return { ask: false, reason: `非文件文档(${value.scheme ?? '未知'})` };
|
|
63
|
+
if (!(Number.isFinite(value.lineCount) && value.lineCount > 0)) return { ask: false, reason: '空文档' };
|
|
64
|
+
if (value.lineCount > FIM_MAX_DOCUMENT_LINES) return { ask: false, reason: '文档过大' };
|
|
65
|
+
if (typeof value.prefixText !== 'string' || typeof value.suffixText !== 'string') return { ask: false, reason: '拿不到前后文' };
|
|
66
|
+
// 光标前整行都是空白、且前面也没有内容 ⇒ 没什么可续的(新建的空文件、文件开头按回车)。
|
|
67
|
+
if (typeof value.lineTextBefore === 'string' && value.lineTextBefore.trim() === '') {
|
|
68
|
+
if (value.prefixText.trim() === '' && value.suffixText.trim() === '') return { ask: false, reason: '空上下文' };
|
|
69
|
+
}
|
|
70
|
+
return { ask: true, reason: 'ok' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 前后文的指纹(缓存键):只看窗口内的内容,与路径/语言一起构成键。 */
|
|
74
|
+
function fingerprint(prefix, suffix, language) {
|
|
75
|
+
const text = `${language ?? ''}\u0000${prefix ?? ''}\u0000${suffix ?? ''}`;
|
|
76
|
+
let hash = 2166136261;
|
|
77
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
78
|
+
hash ^= text.charCodeAt(i);
|
|
79
|
+
hash = Math.imul(hash, 16777619);
|
|
80
|
+
}
|
|
81
|
+
return `${text.length}:${(hash >>> 0).toString(36)}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 构造发往宿主的载荷(只含窗口内的前后文)。
|
|
86
|
+
* @returns {{prompt: string, suffix: string, language: string, path: string}}
|
|
87
|
+
*/
|
|
88
|
+
function buildPayload({ prefixText, suffixText, language, path }) {
|
|
89
|
+
return {
|
|
90
|
+
prompt: trimWindow(prefixText ?? '', FIM_MAX_PREFIX_CHARS, FIM_MAX_PREFIX_LINES, true),
|
|
91
|
+
suffix: trimWindow(suffixText ?? '', FIM_MAX_SUFFIX_CHARS, FIM_MAX_SUFFIX_LINES, false),
|
|
92
|
+
language: typeof language === 'string' ? language : '',
|
|
93
|
+
path: typeof path === 'string' ? path : '',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 有界 + 带 TTL 的结果缓存。 */
|
|
98
|
+
function createCompletionCache({ max = FIM_CACHE_MAX, ttlMs = FIM_CACHE_TTL_MS, now = () => Date.now() } = {}) {
|
|
99
|
+
const store = new Map();
|
|
100
|
+
return {
|
|
101
|
+
get(key) {
|
|
102
|
+
const hit = store.get(key);
|
|
103
|
+
if (hit === undefined) return null;
|
|
104
|
+
if (now() - hit.at > ttlMs) {
|
|
105
|
+
store.delete(key);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
store.delete(key);
|
|
109
|
+
store.set(key, hit);
|
|
110
|
+
return hit.text;
|
|
111
|
+
},
|
|
112
|
+
set(key, text) {
|
|
113
|
+
if (typeof key !== 'string' || key === '' || typeof text !== 'string') return false;
|
|
114
|
+
store.delete(key);
|
|
115
|
+
store.set(key, { text, at: now() });
|
|
116
|
+
while (store.size > max) {
|
|
117
|
+
const oldest = store.keys().next();
|
|
118
|
+
if (oldest.done === true) break;
|
|
119
|
+
store.delete(oldest.value);
|
|
120
|
+
}
|
|
121
|
+
return true;
|
|
122
|
+
},
|
|
123
|
+
size() {
|
|
124
|
+
return store.size;
|
|
125
|
+
},
|
|
126
|
+
clear() {
|
|
127
|
+
store.clear();
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 数字 → 状态栏用短串(1.2k / 12k / 1.2M)。 */
|
|
133
|
+
function formatTokens(n) {
|
|
134
|
+
if (!Number.isFinite(n) || n <= 0) return '0';
|
|
135
|
+
if (n < 1000) return String(Math.round(n));
|
|
136
|
+
if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
|
|
137
|
+
if (n < 1000000) return `${Math.round(n / 1000)}k`;
|
|
138
|
+
return `${(n / 1000000).toFixed(1)}M`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 状态栏 tooltip 里那段 FIM 说明(纯字符串组装,便于单测)。 */
|
|
142
|
+
function describeFimUsage(fim) {
|
|
143
|
+
if (fim === null || typeof fim !== 'object') return [];
|
|
144
|
+
if (fim.enabled !== true) return ['FIM 补全:已关闭(设置里可开,实验性)'];
|
|
145
|
+
const lines = [
|
|
146
|
+
`FIM 补全(实验性):已开启${fim.available === false ? ` —— 不可用:${fim.reason ?? '原因未知'}` : ''}`,
|
|
147
|
+
`调用 ${fim.calls ?? 0} 次(成功 ${fim.ok ?? 0}/失败 ${fim.failed ?? 0})`,
|
|
148
|
+
`输入 ${formatTokens(fim.inputTokens)} tok · 输出 ${formatTokens(fim.outputTokens)} tok · 缓存命中 ${formatTokens(fim.cacheReadTokens)} tok`,
|
|
149
|
+
`停顿 ${Number.isFinite(fim.debounceMs) ? Math.round(fim.debounceMs) : FIM_DEBOUNCE_DEFAULT_MS}ms · `
|
|
150
|
+
+ `${fim.multiline === false ? '仅单行' : '允许多行'}`
|
|
151
|
+
+ `${Array.isArray(fim.disableGlobs) && fim.disableGlobs.length > 0 ? ` · 禁用 ${fim.disableGlobs.length} 条 glob` : ''}`,
|
|
152
|
+
];
|
|
153
|
+
if (Number.isFinite(fim.lastMs)) lines.push(`最近一次 ${Math.round(fim.lastMs)}ms`);
|
|
154
|
+
if (typeof fim.lastError === 'string' && fim.lastError !== '') lines.push(`最近错误:${fim.lastError}`);
|
|
155
|
+
return lines;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------- 与宿主同语义的两件小事
|
|
159
|
+
//
|
|
160
|
+
// 为什么在这里**再实现一遍**:扩展是随包分发的静态文件,不能 import 宿主代码(ESM/CJS + 树内外),
|
|
161
|
+
// 而这两个判据必须在"发请求之前/收响应之后"本地生效 —— 一致性由 scripts/test-fim.mjs 用同一组
|
|
162
|
+
// 语料对两边做等价断言(与桥常量、窗口常量同一套做法)。
|
|
163
|
+
|
|
164
|
+
/** 与宿主 lib/fim-adapter.mjs 的 FIM_DEBOUNCE_DEFAULT_MS 同值(宿主会在 /sync 里给实际值)。 */
|
|
165
|
+
const FIM_DEBOUNCE_DEFAULT_MS = 250;
|
|
166
|
+
|
|
167
|
+
/** 解析 glob 清单(与宿主 compileGlobList 同语义)。 */
|
|
168
|
+
function compileGlobList(text) {
|
|
169
|
+
if (typeof text !== 'string' || text.trim() === '') return [];
|
|
170
|
+
const seen = new Set();
|
|
171
|
+
const out = [];
|
|
172
|
+
for (const raw of text.split(/[;,\s]+/)) {
|
|
173
|
+
const item = raw.trim().replace(/\\/g, '/').replace(/^\.\//, '');
|
|
174
|
+
if (item === '' || seen.has(item)) continue;
|
|
175
|
+
seen.add(item);
|
|
176
|
+
out.push(item);
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** glob → 正则(与宿主 globToRegExp 同语义:`*` 不跨目录、`**` 跨目录、不含 `/` 只匹配文件名)。 */
|
|
182
|
+
function globToRegExp(glob) {
|
|
183
|
+
let g = typeof glob === 'string' ? glob.trim().replace(/\\/g, '/').replace(/^\.\//, '') : '';
|
|
184
|
+
if (g === '') return null;
|
|
185
|
+
if (g.endsWith('/')) g += '**';
|
|
186
|
+
const anchored = g.includes('/');
|
|
187
|
+
let out = '';
|
|
188
|
+
for (let i = 0; i < g.length; i += 1) {
|
|
189
|
+
const ch = g[i];
|
|
190
|
+
if (ch === '*') {
|
|
191
|
+
if (g[i + 1] === '*') {
|
|
192
|
+
if (g[i + 2] === '/') {
|
|
193
|
+
out += '(?:.*/)?';
|
|
194
|
+
i += 2;
|
|
195
|
+
} else {
|
|
196
|
+
out += '.*';
|
|
197
|
+
i += 1;
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
out += '[^/]*';
|
|
201
|
+
}
|
|
202
|
+
} else if (ch === '?') {
|
|
203
|
+
out += '[^/]';
|
|
204
|
+
} else {
|
|
205
|
+
out += ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { re: new RegExp(`^${out}$`), anchored };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 路径是否命中禁用清单(返回命中的模式或 null)。
|
|
212
|
+
* 含 `/` 的模式按**完整路径与各层尾段**各试一次(与宿主 lib/fim-adapter.mjs 同语义:
|
|
213
|
+
* 用户写 `vendor/**` 的直觉是"任何层级下的 vendor",而绝对路径永远以 `/` 或盘符开头)。 */
|
|
214
|
+
function matchDisabledGlob(filePath, globs) {
|
|
215
|
+
if (typeof filePath !== 'string' || filePath === '') return null;
|
|
216
|
+
const list = Array.isArray(globs) ? globs : compileGlobList(globs);
|
|
217
|
+
if (list.length === 0) return null;
|
|
218
|
+
const full = filePath.replace(/\\/g, '/');
|
|
219
|
+
const slash = full.lastIndexOf('/');
|
|
220
|
+
const base = slash === -1 ? full : full.slice(slash + 1);
|
|
221
|
+
for (const glob of list) {
|
|
222
|
+
const parsed = globToRegExp(glob);
|
|
223
|
+
if (parsed === null) continue;
|
|
224
|
+
if (!parsed.anchored) {
|
|
225
|
+
if (parsed.re.test(base)) return glob;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (parsed.re.test(full)) return glob;
|
|
229
|
+
for (let i = full.indexOf('/'); i !== -1; i = full.indexOf('/', i + 1)) {
|
|
230
|
+
if (parsed.re.test(full.slice(i + 1))) return glob;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 只保留第一行(宿主在关掉多行时也会做;这里是第二道)。 */
|
|
237
|
+
function toSingleLine(text) {
|
|
238
|
+
if (typeof text !== 'string' || text === '') return '';
|
|
239
|
+
const first = text.split('\n')[0];
|
|
240
|
+
return first.trim() === '' ? '' : first;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
module.exports = {
|
|
244
|
+
FIM_MAX_PREFIX_CHARS,
|
|
245
|
+
FIM_MAX_SUFFIX_CHARS,
|
|
246
|
+
FIM_MAX_PREFIX_LINES,
|
|
247
|
+
FIM_MAX_SUFFIX_LINES,
|
|
248
|
+
FIM_DEBOUNCE_MS,
|
|
249
|
+
FIM_DEBOUNCE_DEFAULT_MS,
|
|
250
|
+
FIM_MAX_DOCUMENT_LINES,
|
|
251
|
+
FIM_CACHE_MAX,
|
|
252
|
+
FIM_CACHE_TTL_MS,
|
|
253
|
+
trimWindow,
|
|
254
|
+
shouldRequest,
|
|
255
|
+
fingerprint,
|
|
256
|
+
buildPayload,
|
|
257
|
+
createCompletionCache,
|
|
258
|
+
formatTokens,
|
|
259
|
+
describeFimUsage,
|
|
260
|
+
compileGlobList,
|
|
261
|
+
globToRegExp,
|
|
262
|
+
matchDisabledGlob,
|
|
263
|
+
toSingleLine,
|
|
264
|
+
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dshcs-editor-bridge",
|
|
3
3
|
"displayName": "DSH Editor Bridge",
|
|
4
|
-
"description": "
|
|
5
|
-
"version": "0.2.
|
|
4
|
+
"description": "对工作区只读地把编辑器状态(未保存缓冲区、诊断、活动选区)提供给 DSH(不写文件、不执行命令);右键提问打开 DSH 页面里的悬浮对话对话框(0.3.59 起是唯一路径:面板在插件客户端半部,正文/思考/上下文都用页面模块表里的官方渲染器,并可就地回答授权请求;拿不到对话框心跳时只给一条提示)。由 dsh-code-server-app 插件安装,可禁用。另有**实验性**的 FIM 内联补全(灰字续写):默认关闭,在插件设置卡里开启;关闭时本扩展连 provider 都不注册,不会发出任何补全请求。",
|
|
5
|
+
"version": "0.2.20",
|
|
6
6
|
"publisher": "dsh-code-server-app",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"private": true,
|
package/lib/bridge.mjs
CHANGED
|
@@ -31,11 +31,14 @@
|
|
|
31
31
|
*
|
|
32
32
|
* ## 安全不变量(改这个文件之前先读这几条)
|
|
33
33
|
*
|
|
34
|
-
* 1. **`BRIDGE_BASE`(`/code-server-bridge`)
|
|
34
|
+
* 1. **`BRIDGE_BASE`(`/code-server-bridge`)命名空间**是**四条只读路由 + 一条有界的模型调用**:
|
|
35
35
|
* `/health`(无鉴权探活)、`/sync`(上报编辑器状态 + 取事件)、`/old`(取写前原文快照)、
|
|
36
|
-
* `/event`(上报意图,如"请宿主打开对话框")
|
|
36
|
+
* `/event`(上报意图,如"请宿主打开对话框"),以及 **`/complete`**(0.3.61,**实验性 FIM 补全**):
|
|
37
|
+
* 它把光标前后的两段文本交给模型换回一段补全文本 —— **仍然不写文件、不执行命令**,
|
|
38
|
+
* 而且默认关(设置里开启前一律 403),有速率/并发/长度/超时上限(见 lib/index.js 的
|
|
39
|
+
* handleBridgeComplete 与 lib/fim-adapter.mjs)。这里**不允许**出现写文件、改文档、执行命令、
|
|
37
40
|
* 拉起进程的路由 —— `bridge.json` 里那个令牌对本机同用户进程可读,爆炸半径必须封在
|
|
38
|
-
* "泄露编辑器里的信息",绝不能变成任意文件写 / 任意命令执行。
|
|
41
|
+
* "泄露编辑器里的信息 + 替用户花一点补全 token",绝不能变成任意文件写 / 任意命令执行。
|
|
39
42
|
* 提问与授权答复不走这里:它们在 DSH 同源的 `/api/code-server/ask/*` 上,调用方是 DSH 页面里的
|
|
40
43
|
* 插件客户端(吃 DSH 自己的 cookie/Origin 校验)。
|
|
41
44
|
* 2. **授权答复的四条约束**(在 `/api/code-server/ask/approve` 上,缺一条就等于开了任意命令执行的后门,
|