dsh-code-server-app 0.3.58 → 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 +215 -98
- package/README.md +186 -94
- package/THIRD_PARTY_NOTICES.md +60 -0
- package/assets/extensions/dshcs-editor-bridge/extension.js +192 -222
- package/assets/extensions/dshcs-editor-bridge/lib/bridge-client.js +43 -39
- 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-session.mjs +55 -5
- package/lib/bridge.mjs +14 -7
- package/lib/client.js +1061 -90
- package/lib/fim-adapter.mjs +603 -0
- package/lib/index.js +366 -205
- package/package.json +21 -41
- package/vendor/VENDOR.json +5 -5
- package/assets/extensions/dshcs-editor-bridge/lib/ask-panel.js +0 -416
- package/assets/extensions/dshcs-editor-bridge/webview/THIRD-PARTY.md +0 -22
- package/assets/extensions/dshcs-editor-bridge/webview/src/app.jsx +0 -242
- package/assets/extensions/dshcs-editor-bridge/webview/src/approval.jsx +0 -61
- package/assets/extensions/dshcs-editor-bridge/webview/src/official-tokens.css +0 -576
- package/assets/extensions/dshcs-editor-bridge/webview/src/panel.css +0 -380
- package/assets/extensions/dshcs-editor-bridge/webview/src/thread.jsx +0 -160
- package/assets/extensions/dshcs-editor-bridge/webview/thread.css +0 -1
- package/assets/extensions/dshcs-editor-bridge/webview/thread.js +0 -465
|
@@ -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-session.mjs
CHANGED
|
@@ -140,6 +140,37 @@ export function pickAgent(ctx) {
|
|
|
140
140
|
return null;
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* 忙碌时按 Enter 的投递方式 —— **跟着 DSH 自己的设置走**。
|
|
145
|
+
*
|
|
146
|
+
* DSH 的对话插件把这个偏好放在 `ui-conversation` 命名空间的 `busyEnter` 字段
|
|
147
|
+
* (`dsh-client-ui-conversation` 契约:`BUSY_ENTER_BEHAVIORS = ['queue','steer']`,默认 `'queue'`),
|
|
148
|
+
* 用户在 设置 → 对话 里改的就是它。面板里按 Enter 与主界面按 Enter 是同一个手势,所以这里读同一个值:
|
|
149
|
+
* - `'steer'` ⇒ `agent.steer(msg)`(DSH 契约:"an idle driver starts a turn; a running driver
|
|
150
|
+
* consumes it at its **next step boundary**")—— 正在跑的那一轮就能看到你的追问;
|
|
151
|
+
* - `'queue'` ⇒ `agent.followup(msg)`(契约:"becomes the sole ordinary message of **its own turn**")
|
|
152
|
+
* —— 排到下一轮,不打扰当前轮。
|
|
153
|
+
*
|
|
154
|
+
* 读法走官方 API `ctx.settings.get(ns)`(宿主侧 `@deepseek-ai/dsh-settings` 的契约:
|
|
155
|
+
* "Read one registered namespace's resolved value")。读不到(命名空间没注册 / 极简组合 / 老版本 DSH)
|
|
156
|
+
* 一律回默认 `'queue'`,并且**逐项特性探测** `agent.steer` 是否存在 —— 探测失败绝不抛。
|
|
157
|
+
*
|
|
158
|
+
* @param {object} ctx cordis 上下文
|
|
159
|
+
* @returns {'queue'|'steer'}
|
|
160
|
+
*/
|
|
161
|
+
export function pickBusyEnter(ctx) {
|
|
162
|
+
const settings = getService(ctx, 'settings');
|
|
163
|
+
if (settings === undefined || settings === null || typeof settings.get !== 'function') return 'queue';
|
|
164
|
+
try {
|
|
165
|
+
const section = settings.get('ui-conversation');
|
|
166
|
+
const value = section !== null && typeof section === 'object' ? section.busyEnter : undefined;
|
|
167
|
+
return value === 'steer' ? 'steer' : 'queue';
|
|
168
|
+
} catch {
|
|
169
|
+
// 未注册的命名空间在多数实现里是 undefined,但契约允许实现抛 —— 一律当默认值
|
|
170
|
+
return 'queue';
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
143
174
|
/**
|
|
144
175
|
* 投递一条编辑器消息。
|
|
145
176
|
*
|
|
@@ -148,9 +179,10 @@ export function pickAgent(ctx) {
|
|
|
148
179
|
*
|
|
149
180
|
* @param {object} ctx cordis 上下文
|
|
150
181
|
* @param {object} input `composeEditorPrompt` 的输入
|
|
151
|
-
* @
|
|
182
|
+
* @param {{delivery?: 'queue'|'steer'}} [options] 投递方式(缺省按 DSH 设置推导,见 pickBusyEnter)
|
|
183
|
+
* @returns {Promise<{ok: boolean, code?: string, error?: string, sessionId?: string, text?: string, delivery?: string}>}
|
|
152
184
|
*/
|
|
153
|
-
export async function deliverEditorPrompt(ctx, input) {
|
|
185
|
+
export async function deliverEditorPrompt(ctx, input, options = {}) {
|
|
154
186
|
const createUserMessage = await loadDshExport('@deepseek-ai/dsh-llm', 'createUserMessage');
|
|
155
187
|
if (typeof createUserMessage !== 'function') {
|
|
156
188
|
return { ok: false, code: 'NO_LLM', error: '解析不到 @deepseek-ai/dsh-llm 的 createUserMessage(DSH 部署不完整?)' };
|
|
@@ -174,9 +206,24 @@ export async function deliverEditorPrompt(ctx, input) {
|
|
|
174
206
|
} catch (err) {
|
|
175
207
|
return { ok: false, code: 'BAD_MESSAGE', error: `构造消息失败:${err && err.message ? err.message : String(err)}` };
|
|
176
208
|
}
|
|
209
|
+
// 请求方可以显式指定(queue/steer);没指定就跟着 DSH 的 `ui-conversation.busyEnter` 走。
|
|
210
|
+
const requested = options.delivery === 'steer' || options.delivery === 'queue' ? options.delivery : null;
|
|
211
|
+
const busy = agent.status === 'running';
|
|
212
|
+
let delivery = requested ?? pickBusyEnter(ctx);
|
|
213
|
+
// 空闲时两者等价(DSH 契约:steer 在空闲驱动上同样"starts a turn"),但**空闲一律走 followup** ——
|
|
214
|
+
// 语义最直白,也不依赖 steer 存在。
|
|
215
|
+
if (!busy) delivery = 'queue';
|
|
216
|
+
// steer 是 0.3.59 才用到的 API:老宿主没有它时静默退回 queue,绝不因为"设置里写了 steer"就投不出去。
|
|
217
|
+
if (delivery === 'steer' && typeof agent.steer !== 'function') delivery = 'queue';
|
|
177
218
|
try {
|
|
178
|
-
|
|
179
|
-
|
|
219
|
+
if (delivery === 'steer') {
|
|
220
|
+
// steer = "Submit steering for the nearest step":正在跑的那一轮会在下一个 step 边界领取它,
|
|
221
|
+
// 所以追问能当轮被看到(而不是等下一轮)。
|
|
222
|
+
agent.steer(message);
|
|
223
|
+
} else {
|
|
224
|
+
// followup = 下一轮 + 唤醒:agent 空闲就立刻开一轮,在跑就排队,两种情况用户都能看到。
|
|
225
|
+
agent.followup(message);
|
|
226
|
+
}
|
|
180
227
|
} catch (err) {
|
|
181
228
|
return { ok: false, code: 'DELIVER_FAILED', error: `投递失败:${err && err.message ? err.message : String(err)}` };
|
|
182
229
|
}
|
|
@@ -185,5 +232,8 @@ export async function deliverEditorPrompt(ctx, input) {
|
|
|
185
232
|
: (agent.id ?? null);
|
|
186
233
|
// `agent` 是给调用方用的**内部句柄**(授权拦截要拿 `agent.ctx` 注册 agent 作用域监听),
|
|
187
234
|
// 路由返回前必须剥掉:它不可 JSON 序列化,也不该泄漏进编辑器。
|
|
188
|
-
|
|
235
|
+
// `delivery` + `busy` 一并回去:面板据此说清这条追问会怎么被处理 ——
|
|
236
|
+
// 只看 delivery 会把**空闲**时的 followup 误报成"排到下一轮"(实测踩到:空闲时发一条,
|
|
237
|
+
// 面板却写"当前轮结束后…",而当时根本没有当前轮,消息是立刻开的新一轮)。
|
|
238
|
+
return { ok: true, sessionId, text, delivery, busy, agent };
|
|
189
239
|
}
|
package/lib/bridge.mjs
CHANGED
|
@@ -29,18 +29,25 @@
|
|
|
29
29
|
* 挂 `webServer` 前缀(0.3.9–0.3.12)则只有 web profile 有 webServer,desktop 根本没有 HTTP 面。
|
|
30
30
|
* 桥两端本来就是同一台机器上的两个进程,所以传输用本机 IPC,鉴权仍走独立令牌:
|
|
31
31
|
*
|
|
32
|
-
* ## 安全不变量(
|
|
32
|
+
* ## 安全不变量(改这个文件之前先读这几条)
|
|
33
33
|
*
|
|
34
|
-
* 1. **`BRIDGE_BASE`(`/code-server-bridge`)
|
|
35
|
-
* (
|
|
36
|
-
* `
|
|
37
|
-
*
|
|
38
|
-
*
|
|
34
|
+
* 1. **`BRIDGE_BASE`(`/code-server-bridge`)命名空间**是**四条只读路由 + 一条有界的模型调用**:
|
|
35
|
+
* `/health`(无鉴权探活)、`/sync`(上报编辑器状态 + 取事件)、`/old`(取写前原文快照)、
|
|
36
|
+
* `/event`(上报意图,如"请宿主打开对话框"),以及 **`/complete`**(0.3.61,**实验性 FIM 补全**):
|
|
37
|
+
* 它把光标前后的两段文本交给模型换回一段补全文本 —— **仍然不写文件、不执行命令**,
|
|
38
|
+
* 而且默认关(设置里开启前一律 403),有速率/并发/长度/超时上限(见 lib/index.js 的
|
|
39
|
+
* handleBridgeComplete 与 lib/fim-adapter.mjs)。这里**不允许**出现写文件、改文档、执行命令、
|
|
40
|
+
* 拉起进程的路由 —— `bridge.json` 里那个令牌对本机同用户进程可读,爆炸半径必须封在
|
|
41
|
+
* "泄露编辑器里的信息 + 替用户花一点补全 token",绝不能变成任意文件写 / 任意命令执行。
|
|
42
|
+
* 提问与授权答复不走这里:它们在 DSH 同源的 `/api/code-server/ask/*` 上,调用方是 DSH 页面里的
|
|
43
|
+
* 插件客户端(吃 DSH 自己的 cookie/Origin 校验)。
|
|
44
|
+
* 2. **授权答复的四条约束**(在 `/api/code-server/ask/approve` 上,缺一条就等于开了任意命令执行的后门,
|
|
45
|
+
* 不许放宽):
|
|
39
46
|
* (a) 它只能**回答**已经存在的授权请求,不能发起任何动作 —— 请求体只有 `{id, outcome}`,
|
|
40
47
|
* 不接受自由文本 / 路径 / 命令参数;
|
|
41
48
|
* (b) `id` 必须是本进程自己发起、且**仍未决**的请求(用后即废,单次使用);
|
|
42
49
|
* (c) `outcome` 只接受 `allowed-once` / `rejected` 两个白名单值,没有"永久允许";
|
|
43
|
-
* (d)
|
|
50
|
+
* (d) 对话框没在看 / 窗口超时(默认 5 分钟)→ **交回官方链路**,绝不自动放行(DSH 的
|
|
44
51
|
* `approval/request` 本身 fail closed,这里只能把"没人答"保持成"没人答")。
|
|
45
52
|
* 3. **带 `Origin` 的请求一律 403。** 浏览器发起的请求必带 Origin,扩展宿主进程不带。
|
|
46
53
|
* 这条只排除浏览器,不误伤扩展(也顺带挡住 CSRF / DNS rebinding 这类"用你的浏览器打本机端口")。
|