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
package/lib/index.js
CHANGED
|
@@ -51,6 +51,22 @@ import {
|
|
|
51
51
|
writeBridgeConfig,
|
|
52
52
|
} from './bridge.mjs';
|
|
53
53
|
import { bridgeEndpointPath, startBridgeListener } from './bridge-ipc.mjs';
|
|
54
|
+
// FIM(幽灵)补全(实验性):**注册一个自己的 LLM 适配器**而不是直连 —— 见 lib/fim-adapter.mjs 顶部。
|
|
55
|
+
import {
|
|
56
|
+
clampDebounce,
|
|
57
|
+
compileGlobList,
|
|
58
|
+
createApiKeyResolver,
|
|
59
|
+
createFimAdapterClass,
|
|
60
|
+
createFimBudget,
|
|
61
|
+
createFimStats,
|
|
62
|
+
encodeFimRequest,
|
|
63
|
+
FIM_MAX_TOKENS,
|
|
64
|
+
FIM_MODEL,
|
|
65
|
+
FIM_PROVIDER,
|
|
66
|
+
loadFimBase,
|
|
67
|
+
matchDisabledGlob,
|
|
68
|
+
toSingleLine,
|
|
69
|
+
} from './fim-adapter.mjs';
|
|
54
70
|
import { registerEditorPrompt, registerEditorTools, setPromptLiveProbe } from './bridge-tools.mjs';
|
|
55
71
|
import { deliverEditorPrompt } from './bridge-session.mjs';
|
|
56
72
|
import { dshEntry, dshRequire } from './dsh-resolve.mjs';
|
|
@@ -120,6 +136,25 @@ export const Config = z.object({
|
|
|
120
136
|
* 诊断、活动选区),并让编辑器里的动作能驱动 DSH。关闭后不写 bridge.json、不注册编辑器工具,
|
|
121
137
|
* 扩展会休眠(它读不到配置就不做任何事)。安全模型见 lib/bridge.mjs 顶部。 */
|
|
122
138
|
editorBridge: z.boolean().default(true),
|
|
139
|
+
/** fim=false(默认):**实验性**的 FIM(幽灵)补全。开启后宿主的编辑器桥多一条**有界的模型调用**
|
|
140
|
+
* 路由(`POST /complete`),编辑器里的内联补全(灰字)由它供给;宿主为此注册一个自己的 LLM
|
|
141
|
+
* 适配器路由 `dshcs-fim`(走 Completions API 的 `/beta/completions`)。
|
|
142
|
+
*
|
|
143
|
+
* 为什么默认关、为什么叫实验性:①它会把**光标附近的代码**发给模型(桥的其余部分是只读状态,
|
|
144
|
+
* 不外发内容);②模型在"不该补的位置"会硬凑(实测 2/2 复现),需要编辑器侧判据兜;
|
|
145
|
+
* ③这条调用**不进 DSH 的 token 计量**(一次性调用不是 loop 请求,不写会话日志),
|
|
146
|
+
* 用量只在扩展状态栏与设置卡里可见。安全模型见 lib/fim-adapter.mjs 顶部。 */
|
|
147
|
+
fim: z.boolean().default(false),
|
|
148
|
+
/** 停顿多久才发一次补全请求(毫秒)。太小 ⇒ 打字过程中反复触发;太大 ⇒ 失去"补全"的意义。
|
|
149
|
+
* 取值在消费处夹到 [100, 3000](clampDebounce),设置里填什么都不至于打到链路上。 */
|
|
150
|
+
fimDebounceMs: z.number().default(250),
|
|
151
|
+
/** 是否允许多行补全(true 默认)。false ⇒ 宿主只回第一行(空首行 = 这次不补)。
|
|
152
|
+
* 关掉它是最省心的安全阀:实测模型在"不该补的位置"会硬凑,多行会把这种噪声放大。 */
|
|
153
|
+
fimMultiline: z.boolean().default(true),
|
|
154
|
+
/** 按 glob 禁用补全的文件(分号 / 逗号 / 空白 / 换行分隔;空 = 不禁用)。
|
|
155
|
+
* 语义:`*` 不跨目录、`**` 跨目录、不含 `/` 的模式只匹配文件名、`/` 结尾视作 `/**`。
|
|
156
|
+
* 两侧都会判:扩展侧先判(不发请求),宿主侧再判(防线在服务端)。 */
|
|
157
|
+
fimDisableGlobs: z.string().default(''),
|
|
123
158
|
});
|
|
124
159
|
|
|
125
160
|
const DEFAULT_CONFIG = {
|
|
@@ -136,6 +171,12 @@ const DEFAULT_CONFIG = {
|
|
|
136
171
|
readyTimeoutMs: 60000,
|
|
137
172
|
/** 编辑器桥(0.3.0):见 Config.editorBridge。行配置与设置文档都可关;设置卡片暂不提供行。 */
|
|
138
173
|
editorBridge: true,
|
|
174
|
+
/** FIM 补全(实验性,0.3.61):见 Config.fim。默认关。 */
|
|
175
|
+
fim: false,
|
|
176
|
+
/** FIM 的三个子项(0.3.62):停顿毫秒数 / 是否多行 / 按 glob 禁用。见 Config 里的说明。 */
|
|
177
|
+
fimDebounceMs: 250,
|
|
178
|
+
fimMultiline: true,
|
|
179
|
+
fimDisableGlobs: '',
|
|
139
180
|
};
|
|
140
181
|
|
|
141
182
|
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
@@ -337,6 +378,31 @@ export function installBundledExtensions(extensionsDir, userDataDir, options = {
|
|
|
337
378
|
}
|
|
338
379
|
};
|
|
339
380
|
walkDst('');
|
|
381
|
+
// 文件删完还要**收掉空目录**:0.3.59 删掉了整个 webview/ 子目录,只删文件的话会在用户的
|
|
382
|
+
// VS Code 树里留下一串空目录(实测:webview/、webview/src、webview/fonts 各 0 字节)。
|
|
383
|
+
// 自底向上收,只收"确实空了"的目录(非空的目录说明里面有别的东西,不动)。
|
|
384
|
+
const pruneEmptyDirs = (rel) => {
|
|
385
|
+
const abs = rel === '' ? dst : path.join(dst, rel);
|
|
386
|
+
let entries;
|
|
387
|
+
try {
|
|
388
|
+
entries = fs.readdirSync(abs, { withFileTypes: true });
|
|
389
|
+
} catch {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
for (const ent of entries) {
|
|
393
|
+
if (ent.isDirectory()) pruneEmptyDirs(rel === '' ? ent.name : `${rel}/${ent.name}`);
|
|
394
|
+
}
|
|
395
|
+
if (rel === '') return;
|
|
396
|
+
try {
|
|
397
|
+
if (fs.readdirSync(abs).length === 0) {
|
|
398
|
+
fs.rmdirSync(abs);
|
|
399
|
+
removed.push(`${rel}/`);
|
|
400
|
+
}
|
|
401
|
+
} catch {
|
|
402
|
+
// 竞态(别的东西刚写进来)或权限问题:保持原样,不影响主流程
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
pruneEmptyDirs('');
|
|
340
406
|
}
|
|
341
407
|
// 清理放错位置的副本:编辑器桥的旧用户级副本(0.3.11 及更早)/ dshcs-open-file 的旧用户级副本。
|
|
342
408
|
const wrong = extensionTarget(extensionsDir, ext.name, ext.placement === 'builtin' ? 'user' : 'builtin', treeRoot);
|
|
@@ -590,6 +656,10 @@ function readEndpoint(config) {
|
|
|
590
656
|
}
|
|
591
657
|
}
|
|
592
658
|
|
|
659
|
+
/** 仅测试用:最近一次 apply() 装好的桥分发器(消费方是文件末尾的 bridgeRequestForTests)。
|
|
660
|
+
* 运行时代码**不读它** —— 它存在的唯一目的是让回归不必经过命名管道,原因见那个函数的注释。 */
|
|
661
|
+
let activeBridgeDispatch = null;
|
|
662
|
+
|
|
593
663
|
export async function apply(ctx, config) {
|
|
594
664
|
const cfg = { ...DEFAULT_CONFIG, ...(config ?? {}) };
|
|
595
665
|
|
|
@@ -607,6 +677,12 @@ export async function apply(ctx, config) {
|
|
|
607
677
|
let fullscreenOnOpen = true; // 客户端"打开标签即全屏右侧栏"(0.2.9)
|
|
608
678
|
/** 编辑器桥开关(0.3.0):行配置 cfg.editorBridge 是种子,设置文档里可改并实时生效。 */
|
|
609
679
|
let bridgeSetting = cfg.editorBridge !== false;
|
|
680
|
+
/** FIM 补全开关(实验性,0.3.61):行配置 cfg.fim 是种子,设置文档里可改并实时生效。默认关。 */
|
|
681
|
+
let fimSetting = cfg.fim === true;
|
|
682
|
+
/** FIM 的三个子项(0.3.62):停顿毫秒数 / 是否多行 / 按 glob 禁用(原始文本,消费时解析)。 */
|
|
683
|
+
let fimDebounceMs = clampDebounce(cfg.fimDebounceMs);
|
|
684
|
+
let fimMultiline = cfg.fimMultiline !== false;
|
|
685
|
+
let fimDisableGlobs = typeof cfg.fimDisableGlobs === 'string' ? cfg.fimDisableGlobs : '';
|
|
610
686
|
if (settingsSvc !== undefined && typeof settingsSvc.register === 'function') {
|
|
611
687
|
try {
|
|
612
688
|
const scope = settingsSvc.register(SETTINGS_NS, Config);
|
|
@@ -620,6 +696,10 @@ export async function apply(ctx, config) {
|
|
|
620
696
|
: DEFAULT_CLAIM_EXTENSIONS;
|
|
621
697
|
fullscreenOnOpen = resolved && typeof resolved.fullscreenOnOpen === 'boolean' ? resolved.fullscreenOnOpen : true;
|
|
622
698
|
bridgeSetting = resolved && typeof resolved.editorBridge === 'boolean' ? resolved.editorBridge : bridgeSetting;
|
|
699
|
+
fimSetting = resolved && typeof resolved.fim === 'boolean' ? resolved.fim : fimSetting;
|
|
700
|
+
fimDebounceMs = clampDebounce(resolved && resolved.fimDebounceMs !== undefined ? resolved.fimDebounceMs : fimDebounceMs);
|
|
701
|
+
fimMultiline = resolved && typeof resolved.fimMultiline === 'boolean' ? resolved.fimMultiline : fimMultiline;
|
|
702
|
+
fimDisableGlobs = resolved && typeof resolved.fimDisableGlobs === 'string' ? resolved.fimDisableGlobs : fimDisableGlobs;
|
|
623
703
|
}
|
|
624
704
|
scope.watch((next) => {
|
|
625
705
|
if (next != null && (next.serve === 'dsh' || next.serve === 'loopback')) {
|
|
@@ -646,6 +726,36 @@ export async function apply(ctx, config) {
|
|
|
646
726
|
}
|
|
647
727
|
}
|
|
648
728
|
}
|
|
729
|
+
// FIM(实验性):开 → 注册我们自己的 LLM 适配器路由(编辑器补全要靠它);
|
|
730
|
+
// 关 → 注销。翻转立刻生效,不需要重启宿主(扩展侧下一趟 /sync 就会看到 enabled 变了)。
|
|
731
|
+
if (next != null && typeof next.fim === 'boolean') {
|
|
732
|
+
if (next.fim !== fimSetting) {
|
|
733
|
+
fimSetting = next.fim;
|
|
734
|
+
console.log(`[code-server] fim updated: ${fimSetting}(实验性 FIM 补全)`);
|
|
735
|
+
void ensureFimAdapter();
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
// FIM 的三个子项(0.3.62):都**即时生效**,不需要重启宿主 —— 扩展每趟 /sync 都会拿到新值
|
|
739
|
+
// (停顿与 glob 是扩展侧行为,多行由宿主在返回前裁)。改动只打一行日志,便于事后对齐现场。
|
|
740
|
+
if (next != null && next.fimDebounceMs !== undefined) {
|
|
741
|
+
const clamped = clampDebounce(next.fimDebounceMs);
|
|
742
|
+
if (clamped !== fimDebounceMs) {
|
|
743
|
+
fimDebounceMs = clamped;
|
|
744
|
+
console.log(`[code-server] fimDebounceMs updated: ${fimDebounceMs}ms`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
if (next != null && typeof next.fimMultiline === 'boolean') {
|
|
748
|
+
if (next.fimMultiline !== fimMultiline) {
|
|
749
|
+
fimMultiline = next.fimMultiline;
|
|
750
|
+
console.log(`[code-server] fimMultiline updated: ${fimMultiline}`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
if (next != null && typeof next.fimDisableGlobs === 'string') {
|
|
754
|
+
if (next.fimDisableGlobs !== fimDisableGlobs) {
|
|
755
|
+
fimDisableGlobs = next.fimDisableGlobs;
|
|
756
|
+
console.log(`[code-server] fimDisableGlobs updated: ${compileGlobList(fimDisableGlobs).length} 条`);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
649
759
|
if (next != null && typeof next.keepResident === 'boolean') {
|
|
650
760
|
keepResident = next.keepResident;
|
|
651
761
|
console.log(`[code-server] keepResident updated: ${keepResident}`);
|
|
@@ -755,22 +865,19 @@ export async function apply(ctx, config) {
|
|
|
755
865
|
const bridgeApprovalBoard = createApprovalBoard();
|
|
756
866
|
/**
|
|
757
867
|
* 谁在"看着这个会话" —— 授权拦截据此决定要不要抢答(没人看就直接交给官方链路):
|
|
758
|
-
* - `ids`:编辑器扩展面板声明要看的会话(每趟 /sync 上报);
|
|
759
868
|
* - `dialogIds`:DSH 页面里的对话框(0.3.24)声明要看的会话;
|
|
760
|
-
* - `at`:最近一次声明的时间(
|
|
869
|
+
* - `at`:最近一次声明的时间(诊断用)。
|
|
870
|
+
*
|
|
871
|
+
* 0.3.59:编辑器扩展的 webview 面板退役后只剩对话框一路 —— 以前还有 `ids`(扩展每趟 /sync 上报的
|
|
872
|
+
* 观看列表),两个来源求并集、还要按心跳新鲜度区分,现在都不需要了。
|
|
761
873
|
*/
|
|
762
|
-
const bridgeWatch = {
|
|
763
|
-
/** 上次回给扩展的对话流修订号(0.3.27:没变化就不重传整份快照)。 */
|
|
764
|
-
let bridgeThreadRevSent = -1;
|
|
874
|
+
const bridgeWatch = { dialogIds: [], at: 0 };
|
|
765
875
|
/** 上次记录过的"有没有人在看"(0.3.30:状态翻转才写诊断日志)。 */
|
|
766
876
|
let bridgeWatchLogged = false;
|
|
767
877
|
/** 首帧是否已写过诊断(0.3.31:"一直没人看"也要留下证据)。 */
|
|
768
878
|
let bridgeWatchWroteFirst = false;
|
|
769
879
|
/** 上次写过的 hasPanel 判据原文(变了才写,避免每 600ms 刷屏)。 */
|
|
770
880
|
let bridgeHasPanelLine = '';
|
|
771
|
-
/** 客户端(self-declared)是否能把授权卡片画出来(0.3.30)。 */
|
|
772
|
-
let bridgeApprovalsUi = false;
|
|
773
|
-
let bridgeApprovalsUiLogged = false;
|
|
774
881
|
|
|
775
882
|
/**
|
|
776
883
|
* 授权/看护状态的**诊断日志**(0.3.30):写到 <home>/.dsh/code-server/bridge-approval.log。
|
|
@@ -795,36 +902,30 @@ export async function apply(ctx, config) {
|
|
|
795
902
|
|
|
796
903
|
/** 当前所有"在看"的会话(去重)。 */
|
|
797
904
|
function watchedSessions() {
|
|
798
|
-
|
|
799
|
-
return [...new Set(all.filter((id) => typeof id === 'string' && id !== ''))].slice(0, 4);
|
|
905
|
+
return [...new Set(bridgeWatch.dialogIds.filter((id) => typeof id === 'string' && id !== ''))].slice(0, 4);
|
|
800
906
|
}
|
|
801
907
|
/** 现在有没有人在看(授权拦截的唯一判据)。 */
|
|
802
908
|
function hasWatcher() {
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
// **对话框是宿主侧状态**(只在 /ask/close 时消失):不拿轮询新鲜度去判它 ——
|
|
806
|
-
// 浏览器把后台标签的定时器节流到分钟级时,900ms 的心跳一断、5 秒窗口就过期,
|
|
909
|
+
// 0.3.59 起只有对话框一路。**对话框是宿主侧状态**(只在 /ask/close 时消失),所以不拿轮询新鲜度
|
|
910
|
+
// 去判它 —— 浏览器把后台标签的定时器节流到分钟级时,900ms 的心跳一断、5 秒窗口就过期,
|
|
807
911
|
// 我们会误判"没人看"而放弃拦截(用户实测:授权卡片因此跑到 DSH 界面)。
|
|
808
|
-
|
|
809
|
-
// 编辑器面板靠 /sync 的 600ms 心跳;窗口放宽到 60 秒(正常心跳远小于它)。
|
|
810
|
-
return Date.now() - bridgeWatch.at < 60000;
|
|
912
|
+
return watchedSessions().length > 0;
|
|
811
913
|
}
|
|
812
914
|
|
|
813
915
|
const bridgeApproval = createApprovalInterceptor({
|
|
814
916
|
board: bridgeApprovalBoard,
|
|
815
917
|
holdMs: DEFAULT_HOLD_MS,
|
|
816
|
-
//
|
|
918
|
+
// **只在对话框真的能显示卡片时才抢答**(0.3.30 / 0.3.59 收紧)。
|
|
817
919
|
// 反例(用户实测):面板处于"宿主没有对话流能力"的坏状态时我们仍然抢答 ⇒ 官方卡片不出现、
|
|
818
920
|
// 面板也不画 ⇒ 用户看到的是"没弹出授权",请求在宿主里干等 5 分钟。
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
// 编辑器面板是 null ⇒ 旧判据误判成"画不出卡片",授权被让给了 DSH 界面。
|
|
921
|
+
// 0.3.59 起编辑器面板退役,"能显示卡片"只剩**对话框开着 + 有人看 + 客户端半部在轮询**一条路
|
|
922
|
+
// (客户端自己声明 approvalsUi 那条也随面板一起删了 —— 声明者已经不存在)。
|
|
822
923
|
hasPanel: () => {
|
|
823
924
|
const watcher = hasWatcher();
|
|
824
925
|
const dialogCapable = askDialogLive();
|
|
825
|
-
const verdict = watcher &&
|
|
926
|
+
const verdict = watcher && dialogCapable;
|
|
826
927
|
// 判据的**原始值**一起落日志(0.3.35):只记结论的话,出问题还是只能猜。
|
|
827
|
-
const line = 'hasPanel -> ' + verdict + ' (hasWatcher=' + watcher
|
|
928
|
+
const line = 'hasPanel -> ' + verdict + ' (hasWatcher=' + watcher
|
|
828
929
|
+ ' dialogLive=' + dialogCapable + ' polls=' + askDialog.polls + ' open=' + askDialog.open
|
|
829
930
|
+ ' session=' + String(askDialog.sessionId) + ')';
|
|
830
931
|
if (line !== bridgeHasPanelLine) { bridgeHasPanelLine = line; bridgeDiag(line); }
|
|
@@ -839,11 +940,15 @@ export async function apply(ctx, config) {
|
|
|
839
940
|
/**
|
|
840
941
|
* 「问 DSH」对话框的宿主侧状态(0.3.24)。
|
|
841
942
|
*
|
|
842
|
-
*
|
|
843
|
-
* 面板是编辑器里的一个 tab/一列,而用户要的是"像 DSH 那样的对话框"。host 这边只负责:
|
|
943
|
+
* 对话只有一个宿主:**DSH 页面里的浮动对话框**(client 半部,见 lib/client.js)。host 这边只负责:
|
|
844
944
|
* - 打开/关闭状态(编辑器扩展右键 → 桥 `/event` 上报 `ask-open`);
|
|
845
945
|
* - 状态与动作的 HTTP 面(`/api/code-server/ask/*`,与 DSH 同源、吃它的 cookie/Origin 校验);
|
|
846
|
-
* -
|
|
946
|
+
* - 把"被看的会话"投影成有界条目流(见 lib/bridge-thread.mjs)与待决授权快照。
|
|
947
|
+
*
|
|
948
|
+
* **0.3.59 起不再往页面里送产物,也不再服务编辑器面板**:面板就在 `lib/client.js` 里,渲染器走
|
|
949
|
+
* DSH 页面的模块表(`react-dom/client` + `@deepseek-ai/dsh-client-ui-primitives`);
|
|
950
|
+
* `/ask/bundle` 路由、`askBundle()` 与编辑器里的 webview 面板在同一版里一起删掉
|
|
951
|
+
* (thread.js / thread.css / build-webview.mjs / esbuild 那一整套)。
|
|
847
952
|
*/
|
|
848
953
|
const askDialog = { open: false, armed: false, mode: 'selection', at: 0, sessionId: null, polls: 0, lastPollAt: 0 };
|
|
849
954
|
|
|
@@ -852,8 +957,8 @@ export async function apply(ctx, config) {
|
|
|
852
957
|
*
|
|
853
958
|
* 0.3.24 的教训:宿主无条件报 `askDialog: true`,扩展于是不再开编辑器面板;而客户端半部只要没跑起来
|
|
854
959
|
* (浏览器还缓存着旧 bundle / 没硬刷新),就**没有任何一方在看这个会话** ⇒ 授权悄悄落到 DSH 界面的
|
|
855
|
-
* 官方卡片,用户在编辑器里什么都看不到。现在只有**真的收到过 `/ask/state` 轮询**(
|
|
856
|
-
*
|
|
960
|
+
* 官方卡片,用户在编辑器里什么都看不到。现在只有**真的收到过 `/ask/state` 轮询**(10 分钟内)才算有
|
|
961
|
+
* 这个能力;0.3.59 起不满足时扩展也不再退编辑器面板(那一半已退役),而是明确提示用户刷新/打开页面。
|
|
857
962
|
*/
|
|
858
963
|
function askDialogLive() {
|
|
859
964
|
// 对话框每 900ms 问一次 /ask/state —— 那就是"活跃心跳"。心跳一旦建立,在 TTL 内
|
|
@@ -922,7 +1027,8 @@ export async function apply(ctx, config) {
|
|
|
922
1027
|
statusText: '',
|
|
923
1028
|
error: null,
|
|
924
1029
|
sessionId: snapshot.sessionId ?? askDialog.sessionId ?? null,
|
|
925
|
-
uiVersion
|
|
1030
|
+
// 0.3.59:`uiVersion` 只服务过"面板内置渲染器与界面版本比对"那行告警 —— 面板退役后删掉。
|
|
1031
|
+
// `dshVersion` 留着:诊断时一眼看出这套宿主跑在哪个 DSH 上。
|
|
926
1032
|
dshVersion: dshVersion(),
|
|
927
1033
|
};
|
|
928
1034
|
}
|
|
@@ -932,10 +1038,12 @@ export async function apply(ctx, config) {
|
|
|
932
1038
|
return bridgeThread.rev(watchedSessions()) * 8 + bridgeApprovalBoard.rev() * 8 + (askDialog.open ? 1 : 0) + (askDialog.mode === 'file' ? 2 : 0);
|
|
933
1039
|
}
|
|
934
1040
|
|
|
935
|
-
/** 对话框刷新它的"我在看"时间戳(每趟 /state 都算一次)。
|
|
1041
|
+
/** 对话框刷新它的"我在看"时间戳(每趟 /state 都算一次)。
|
|
1042
|
+
* 0.3.59 起这里也负责对齐会话流订阅:以前是扩展每 600ms 的 `/sync` 顺带做的。 */
|
|
936
1043
|
function refreshDialogWatch() {
|
|
937
1044
|
bridgeWatch.dialogIds = askDialog.open && askDialog.sessionId !== null ? [askDialog.sessionId] : [];
|
|
938
1045
|
bridgeWatch.at = Date.now();
|
|
1046
|
+
bridgeThread.sync(watchedSessions());
|
|
939
1047
|
}
|
|
940
1048
|
|
|
941
1049
|
/**
|
|
@@ -952,29 +1060,6 @@ export async function apply(ctx, config) {
|
|
|
952
1060
|
return { status: ok ? 200 : 409, body: { ok, outcome: ok ? outcome : null } };
|
|
953
1061
|
}
|
|
954
1062
|
|
|
955
|
-
/** 面板产物(thread.js / thread.css):对话框把它们注入 DSH 页面,所以由 host 读盘给文本。 */
|
|
956
|
-
let askBundleCache = null;
|
|
957
|
-
function askBundle() {
|
|
958
|
-
if (askBundleCache !== null) return askBundleCache;
|
|
959
|
-
const dir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'assets', 'extensions', 'dshcs-editor-bridge', 'webview');
|
|
960
|
-
const read = (name) => {
|
|
961
|
-
try {
|
|
962
|
-
return fs.readFileSync(path.join(dir, name), 'utf8');
|
|
963
|
-
} catch {
|
|
964
|
-
return null;
|
|
965
|
-
}
|
|
966
|
-
};
|
|
967
|
-
const js = read('thread.js');
|
|
968
|
-
const css = read('thread.css');
|
|
969
|
-
// 产物缺失(打包漏了 build:webview)→ 明确报出来,不返回半个面板。
|
|
970
|
-
if (js === null || css === null) {
|
|
971
|
-
console.warn(`[code-server] 「问 DSH」对话框的产物缺失:${dir}(请在源码里跑 pnpm run build:webview)`);
|
|
972
|
-
return null;
|
|
973
|
-
}
|
|
974
|
-
askBundleCache = { js, css };
|
|
975
|
-
return askBundleCache;
|
|
976
|
-
}
|
|
977
|
-
|
|
978
1063
|
|
|
979
1064
|
/** 本部署的 DSH 版本(诊断用;面板要的是下面那个"界面版本")。 */
|
|
980
1065
|
function dshVersion() {
|
|
@@ -988,36 +1073,6 @@ export async function apply(ctx, config) {
|
|
|
988
1073
|
}
|
|
989
1074
|
}
|
|
990
1075
|
|
|
991
|
-
/**
|
|
992
|
-
* 本部署**界面**的版本(0.3.22)。
|
|
993
|
-
*
|
|
994
|
-
* 面板里的 markdown 渲染器是从 `@deepseek-ai/dsh-client-ui-primitives` 打包进去的,而那份
|
|
995
|
-
* 渲染器就装在 `@deepseek-ai/dsh-web-frontend` 里(DSH Web 的前端包)。面板拿这个版本和
|
|
996
|
-
* 自己打包时的版本比一比,不一致就在面板顶部显示一行提示 —— 版本错配的排版差异不许静默。
|
|
997
|
-
*/
|
|
998
|
-
function uiVersion() {
|
|
999
|
-
try {
|
|
1000
|
-
const entry = dshEntry();
|
|
1001
|
-
if (entry === undefined || entry === null) return null;
|
|
1002
|
-
const dir = path.dirname(entry);
|
|
1003
|
-
const candidates = [
|
|
1004
|
-
path.join(dir, 'node_modules', '@deepseek-ai', 'dsh-web-frontend', 'package.json'),
|
|
1005
|
-
path.join(dir, '..', 'dsh-web-frontend', 'package.json'),
|
|
1006
|
-
];
|
|
1007
|
-
for (const candidate of candidates) {
|
|
1008
|
-
try {
|
|
1009
|
-
const manifest = JSON.parse(fs.readFileSync(candidate, 'utf8'));
|
|
1010
|
-
if (typeof manifest.version === 'string') return manifest.version;
|
|
1011
|
-
} catch {
|
|
1012
|
-
// 下一个候选
|
|
1013
|
-
}
|
|
1014
|
-
}
|
|
1015
|
-
return null;
|
|
1016
|
-
} catch {
|
|
1017
|
-
return null;
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
1076
|
/** 本次启动生成的新桥令牌;null = 本实例没有令牌(adopt 旧实例时会回读 bridge.json)。 */
|
|
1022
1077
|
let bridgeToken = null;
|
|
1023
1078
|
/** 桥的本机 IPC 监听口(0.3.13)。null = 未起(未启用/起失败)。*/
|
|
@@ -1084,15 +1139,20 @@ export async function apply(ctx, config) {
|
|
|
1084
1139
|
|
|
1085
1140
|
/** 桥的路由(后缀 → Fetch 风格 handler)。做成函数而非常量:handler 声明在文件后段,
|
|
1086
1141
|
* 函数声明会提升,而常量在挂载时可能还在 TDZ。
|
|
1087
|
-
*
|
|
1142
|
+
*
|
|
1143
|
+
* **四条只读路由 + 一条有界的模型调用**(0.3.61 起):/health 探活、/sync 上报状态+取事件、
|
|
1144
|
+
* /old 取写前原文、/event 上报意图;/complete 是**唯一的例外** —— 它把光标前后的两段文本
|
|
1145
|
+
* 交给模型换回一段补全文本(实验性 FIM,设置里默认关;安全不变量见 handleBridgeComplete)。
|
|
1146
|
+
* (请宿主打开对话框等)。提问与授权**不在这里** —— 它们走 DSH 同源的
|
|
1147
|
+
* `/api/code-server/ask/{send,approve}`,调用方是 DSH 页面里的插件客户端(client 半部),
|
|
1148
|
+
* 吃 DSH 自己的 cookie/Origin 校验。 */
|
|
1088
1149
|
function bridgeRouteTable() {
|
|
1089
1150
|
return [
|
|
1090
1151
|
{ suffix: '/health', methods: ['GET'], fetch: handleBridgeHealth },
|
|
1091
1152
|
{ suffix: '/sync', methods: ['POST'], fetch: handleBridgeSync },
|
|
1092
1153
|
{ suffix: '/old', methods: ['GET'], fetch: handleBridgeOld },
|
|
1093
|
-
{ suffix: '/ask', methods: ['POST'], fetch: handleBridgeAsk },
|
|
1094
1154
|
{ suffix: '/event', methods: ['POST'], fetch: handleBridgeEvent },
|
|
1095
|
-
{ suffix: '/
|
|
1155
|
+
{ suffix: '/complete', methods: ['POST'], fetch: handleBridgeComplete },
|
|
1096
1156
|
];
|
|
1097
1157
|
}
|
|
1098
1158
|
|
|
@@ -1136,6 +1196,11 @@ export async function apply(ctx, config) {
|
|
|
1136
1196
|
return route.fetch(request);
|
|
1137
1197
|
}
|
|
1138
1198
|
|
|
1199
|
+
// 仅测试用:把分发器交给模块级钩子(见 bridgeRequestForTests)。沙箱里**连接**命名管道 EPERM
|
|
1200
|
+
// ⇒ 走 IPC 的用例会全体 SKIP,"本地全绿"里就没有桥的状态码断言了(0.3.63 的 /complete 403
|
|
1201
|
+
// 就是这么假绿到 runner 才红的)。有了它,断言在任何机器上都能真跑。
|
|
1202
|
+
activeBridgeDispatch = dispatchBridge;
|
|
1203
|
+
|
|
1139
1204
|
/** 桥是否启用(行配置为种子,设置文档里可实时改)。 */
|
|
1140
1205
|
function bridgeEnabled() {
|
|
1141
1206
|
return bridgeSetting !== false;
|
|
@@ -1145,6 +1210,10 @@ export async function apply(ctx, config) {
|
|
|
1145
1210
|
* 所以在 IDE 从未启动的部署里它也只是一段空注册,不产生任何提示词开销。 */
|
|
1146
1211
|
setPromptLiveProbe(() => bridgeMeta !== null);
|
|
1147
1212
|
bridgePromptDispose = registerEditorPrompt(ctx);
|
|
1213
|
+
// FIM(实验性)的适配器注册**不在这里**:它的状态声明(fimStats/fimAdapterDispose…)在文件后段,
|
|
1214
|
+
// 这里调用会撞 `Cannot access 'fimAdapterDispose' before initialization`(TDZ)——
|
|
1215
|
+
// async 函数声明会提升,但它读的 `let` 还没初始化,异常会直接把整个插件加载打挂
|
|
1216
|
+
// (与 0.3.6 的 `ctx.systemPrompt` 事故同一类)。真正的调用点在 FIM 运行时声明之后。
|
|
1148
1217
|
if (bridgePromptDispose === null && bridgeEnabled()) {
|
|
1149
1218
|
console.warn('[code-server] 编辑器桥:systemPrompt 服务不可用,提示词段落未注册(工具仍可用)');
|
|
1150
1219
|
}
|
|
@@ -1159,12 +1228,12 @@ export async function apply(ctx, config) {
|
|
|
1159
1228
|
});
|
|
1160
1229
|
|
|
1161
1230
|
// 会话流与授权拦截都是**按会话**建立的(在 /ask 成功时 watch + intercept),
|
|
1162
|
-
//
|
|
1231
|
+
// 这里只登记卸载钩子:对话框关掉 / IDE 停掉时把订阅与 pending 授权一起收干净。
|
|
1163
1232
|
bridgeAnswerDispose = () => {
|
|
1164
1233
|
bridgeThread.dispose();
|
|
1165
1234
|
bridgeApproval.dispose();
|
|
1166
1235
|
bridgeApprovalBoard.clear();
|
|
1167
|
-
bridgeWatch.
|
|
1236
|
+
bridgeWatch.dialogIds = [];
|
|
1168
1237
|
bridgeWatch.at = 0;
|
|
1169
1238
|
};
|
|
1170
1239
|
|
|
@@ -1958,6 +2027,172 @@ export async function apply(ctx, config) {
|
|
|
1958
2027
|
return jsonResponse({ ok: false, error: reason }, 503);
|
|
1959
2028
|
}
|
|
1960
2029
|
|
|
2030
|
+
// ---------------------------------------------------------------- FIM(幽灵)补全(实验性,0.3.61)
|
|
2031
|
+
|
|
2032
|
+
/** 用量与调用计数:这是**唯一**的可见处(状态栏 + 设置卡)。一次性插件调用不是 loop 请求、
|
|
2033
|
+
* 不写会话日志 ⇒ DSH 的 token-meter 看不到它(见 lib/fim-adapter.mjs 顶部"计量会落在哪")。 */
|
|
2034
|
+
const fimStats = createFimStats();
|
|
2035
|
+
/** 速率/并发闸:这条链路由击键触发,任何一处无界都会变成"打字时把宿主/账号打满"。 */
|
|
2036
|
+
const fimBudget = createFimBudget();
|
|
2037
|
+
/** 已注册的适配器句柄(null = 未注册)。 */
|
|
2038
|
+
let fimAdapterDispose = null;
|
|
2039
|
+
/** 消息构造器(从 DSH 解析;拿不到就不启用 —— 手搓 message 对象容易缺字段,不值得赌)。 */
|
|
2040
|
+
let fimCreateUserMessage = null;
|
|
2041
|
+
/** 不可用的原因(状态栏与设置卡要能说清"为什么没反应")。 */
|
|
2042
|
+
let fimUnavailable = '未启用';
|
|
2043
|
+
|
|
2044
|
+
/**
|
|
2045
|
+
* 按设置注册/注销适配器路由。
|
|
2046
|
+
*
|
|
2047
|
+
* 为什么是**适配器**而不是直连 fetch:这样这条调用仍然走 DSH 的 LLM 服务 —— 取消、超时、
|
|
2048
|
+
* 终态 chunk、稳定错误码都按服务契约走(见 lib/fim-adapter.mjs 顶部)。解析不到 llm 服务或
|
|
2049
|
+
* LlmAdapter 时**不报错、只是不可用**:FIM 是可选能力,不该让插件在缺服务的部署里出问题。
|
|
2050
|
+
*/
|
|
2051
|
+
async function ensureFimAdapter() {
|
|
2052
|
+
if (fimAdapterDispose !== null) {
|
|
2053
|
+
try { fimAdapterDispose(); } catch { /* 已被 fiber 回收 */ }
|
|
2054
|
+
fimAdapterDispose = null;
|
|
2055
|
+
}
|
|
2056
|
+
if (!fimSetting) {
|
|
2057
|
+
fimUnavailable = '设置里未开启';
|
|
2058
|
+
return false;
|
|
2059
|
+
}
|
|
2060
|
+
const llm = (() => { try { return ctx.get('llm'); } catch { return undefined; } })();
|
|
2061
|
+
if (llm === undefined || llm === null || typeof llm.registerAdapter !== 'function') {
|
|
2062
|
+
fimUnavailable = '这个 DSH 没有 llm 服务';
|
|
2063
|
+
console.warn('[code-server] FIM 补全:ctx.llm 不可用,补全保持关闭');
|
|
2064
|
+
return false;
|
|
2065
|
+
}
|
|
2066
|
+
const base = await loadFimBase();
|
|
2067
|
+
if (base === null || base.createUserMessage === null) {
|
|
2068
|
+
fimUnavailable = '解析不到 @deepseek-ai/dsh-llm 的 LlmAdapter/createUserMessage';
|
|
2069
|
+
console.warn(`[code-server] FIM 补全:${fimUnavailable};补全保持关闭`);
|
|
2070
|
+
return false;
|
|
2071
|
+
}
|
|
2072
|
+
try {
|
|
2073
|
+
const Adapter = createFimAdapterClass(base.Base, {
|
|
2074
|
+
resolveApiKey: createApiKeyResolver(ctx),
|
|
2075
|
+
attributionHeaders: base.attributionHeaders,
|
|
2076
|
+
assertUsableApiKey: base.assertUsableApiKey,
|
|
2077
|
+
log: (message) => console.log(`[code-server] FIM:${message}`),
|
|
2078
|
+
});
|
|
2079
|
+
const handle = llm.registerAdapter([FIM_PROVIDER], new Adapter());
|
|
2080
|
+
fimAdapterDispose = typeof handle === 'function' ? handle : () => {};
|
|
2081
|
+
fimCreateUserMessage = base.createUserMessage;
|
|
2082
|
+
fimUnavailable = null;
|
|
2083
|
+
console.log(`[code-server] FIM 补全(实验性)已注册适配器路由:${FIM_PROVIDER}/${FIM_MODEL}`);
|
|
2084
|
+
return true;
|
|
2085
|
+
} catch (error) {
|
|
2086
|
+
fimUnavailable = error && error.message ? error.message : String(error);
|
|
2087
|
+
console.warn(`[code-server] FIM 补全:注册适配器失败(${fimUnavailable})`);
|
|
2088
|
+
return false;
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
/** 给客户端(/sync)看的快照:开关状态 + 可用性 + 用量。 */
|
|
2093
|
+
function fimSnapshot() {
|
|
2094
|
+
const stats = fimStats.snapshot();
|
|
2095
|
+
const budget = fimBudget.snapshot();
|
|
2096
|
+
return {
|
|
2097
|
+
enabled: fimSetting,
|
|
2098
|
+
available: fimSetting && fimAdapterDispose !== null,
|
|
2099
|
+
reason: fimUnavailable,
|
|
2100
|
+
provider: FIM_PROVIDER,
|
|
2101
|
+
model: FIM_MODEL,
|
|
2102
|
+
inflight: budget.inflight,
|
|
2103
|
+
callsLastMinute: budget.callsLastMinute,
|
|
2104
|
+
// 三个子项(0.3.62):扩展侧靠这些值决定"等多久、要不要接受多行、这个文件要不要跳过"。
|
|
2105
|
+
debounceMs: fimDebounceMs,
|
|
2106
|
+
multiline: fimMultiline,
|
|
2107
|
+
disableGlobs: compileGlobList(fimDisableGlobs),
|
|
2108
|
+
...stats,
|
|
2109
|
+
};
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
/**
|
|
2113
|
+
* `POST /complete` —— 桥的**第五条路由**,也是唯一一条**会发起模型调用**的路由。
|
|
2114
|
+
*
|
|
2115
|
+
* 安全不变量(比前四条多三条,别放宽):
|
|
2116
|
+
* (a) **只有设置里开了 FIM 才存在** —— 没开一律 403,且不会发出任何网络请求;
|
|
2117
|
+
* (b) **不写文件、不执行命令** —— 输入是两个字符串,输出是一段文本 + 用量;
|
|
2118
|
+
* (c) **有界** —— body 上限、前后缀窗口(适配器侧再裁一遍)、速率/并发闸、单次 4s 超时。
|
|
2119
|
+
* 即:桥令牌泄露时,攻击面是"替用户花一点补全的 token 并读到一段补全文本",
|
|
2120
|
+
* 而不是任意文件写 / 任意命令执行 —— 与前四条保持同一量级。
|
|
2121
|
+
*/
|
|
2122
|
+
async function handleBridgeComplete(request) {
|
|
2123
|
+
const denied = bridgeRejection(request);
|
|
2124
|
+
if (denied !== null) return denied;
|
|
2125
|
+
if (!fimSetting) return jsonResponse({ ok: false, error: 'fim-disabled' }, 403);
|
|
2126
|
+
if (fimAdapterDispose === null || fimCreateUserMessage === null) {
|
|
2127
|
+
return jsonResponse({ ok: false, error: 'fim-unavailable', reason: fimUnavailable }, 503);
|
|
2128
|
+
}
|
|
2129
|
+
let body;
|
|
2130
|
+
try {
|
|
2131
|
+
body = await readJsonBody(request);
|
|
2132
|
+
} catch {
|
|
2133
|
+
return jsonResponse({ ok: false, error: '请求体不是合法 JSON' }, 400);
|
|
2134
|
+
}
|
|
2135
|
+
if (!bodyWithinLimit(body)) return jsonResponse({ ok: false, error: '请求体过大' }, 413);
|
|
2136
|
+
const prompt = typeof body.prompt === 'string' ? body.prompt : null;
|
|
2137
|
+
const suffix = typeof body.suffix === 'string' ? body.suffix : null;
|
|
2138
|
+
if (prompt === null || suffix === null) return jsonResponse({ ok: false, error: '缺少 prompt/suffix' }, 400);
|
|
2139
|
+
// 按 glob 禁用(0.3.62):扩展侧先判过一遍,这里是**服务端那道**——令牌泄露也只能换来"被拒",
|
|
2140
|
+
// 不会替用户在已禁用的大文件/无关目录上烧 token。
|
|
2141
|
+
const blocked = matchDisabledGlob(typeof body.path === 'string' ? body.path : '', compileGlobList(fimDisableGlobs));
|
|
2142
|
+
if (blocked !== null) return jsonResponse({ ok: false, error: 'disabled-by-glob', glob: blocked }, 403);
|
|
2143
|
+
const gate = fimBudget.acquire();
|
|
2144
|
+
if (gate !== 'ok') return jsonResponse({ ok: false, error: gate }, gate === 'busy' ? 409 : 429);
|
|
2145
|
+
const started = Date.now();
|
|
2146
|
+
try {
|
|
2147
|
+
const llm = ctx.get('llm');
|
|
2148
|
+
const message = fimCreateUserMessage({
|
|
2149
|
+
content: [{ type: 'text', text: encodeFimRequest({ prompt, suffix, language: body.language, path: body.path }) }],
|
|
2150
|
+
});
|
|
2151
|
+
let text = '';
|
|
2152
|
+
let usage = null;
|
|
2153
|
+
let failure = null;
|
|
2154
|
+
for await (const chunk of llm.stream({
|
|
2155
|
+
provider: FIM_PROVIDER,
|
|
2156
|
+
model: FIM_MODEL,
|
|
2157
|
+
messages: [message],
|
|
2158
|
+
maxTokens: FIM_MAX_TOKENS,
|
|
2159
|
+
temperature: 0,
|
|
2160
|
+
})) {
|
|
2161
|
+
if (chunk === null || typeof chunk !== 'object') continue;
|
|
2162
|
+
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text;
|
|
2163
|
+
else if (chunk.type === 'usage') usage = chunk.usage ?? usage;
|
|
2164
|
+
else if (chunk.type === 'finish') {
|
|
2165
|
+
const kind = chunk.reason?.kind;
|
|
2166
|
+
if (kind !== 'stop' && kind !== 'max-tokens') {
|
|
2167
|
+
failure = chunk.reason?.failure ?? { code: String(kind ?? 'error'), message: '补全未正常结束' };
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
const ms = Date.now() - started;
|
|
2172
|
+
if (failure !== null) {
|
|
2173
|
+
fimStats.recordFailure(failure.code, ms);
|
|
2174
|
+
return jsonResponse({ ok: false, error: failure.code, reason: failure.message, ms }, 502);
|
|
2175
|
+
}
|
|
2176
|
+
// "允许多行"关掉时**在返回前裁**(0.3.62):扩展侧拿到的就是最终形态,缓存里也不会存多行;
|
|
2177
|
+
// 首行是空白 ⇒ 返回空串,扩展据此当作"这次不补"(与"模型没给出东西"同一处理)。
|
|
2178
|
+
const finalText = fimMultiline ? text : toSingleLine(text);
|
|
2179
|
+
fimStats.recordOk(usage, ms);
|
|
2180
|
+
return jsonResponse({ ok: true, text: finalText, usage, ms });
|
|
2181
|
+
} catch (error) {
|
|
2182
|
+
const ms = Date.now() - started;
|
|
2183
|
+
const message = error && error.message ? error.message : String(error);
|
|
2184
|
+
fimStats.recordFailure(error && error.code ? error.code : 'ERROR', ms);
|
|
2185
|
+
console.warn(`[code-server] FIM 补全失败:${message}`);
|
|
2186
|
+
return jsonResponse({ ok: false, error: error && error.code ? error.code : 'ERROR', reason: message, ms }, 502);
|
|
2187
|
+
} finally {
|
|
2188
|
+
fimBudget.release();
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
// FIM 运行时的声明都已执行到这里 ⇒ 现在才能安全调用(见前面那段 TDZ 说明)。
|
|
2193
|
+
// **不阻塞启动**(void):它是可选能力,解析不到就只是"补全不可用",不该拖慢插件激活。
|
|
2194
|
+
void ensureFimAdapter();
|
|
2195
|
+
|
|
1961
2196
|
async function handleBridgeHealth() {
|
|
1962
2197
|
// 无鉴权:只回一句"桥活着吗",不含任何编辑器数据(便于重启后一眼确认)。
|
|
1963
2198
|
// 注意:bridge=true 只表示"配置已就绪",**不代表扩展在跑**(见 README 已知限制)。
|
|
@@ -2021,17 +2256,7 @@ export async function apply(ctx, config) {
|
|
|
2021
2256
|
// 把当前最大 seq 一并回去:客户端据此自查"我的游标是不是跑到宿主前面去了"(跨版本升级、
|
|
2022
2257
|
// 接管了别的宿主留下的游标文件等),发现后退回 since=0 重新对齐,免得永久失聪。
|
|
2023
2258
|
const lastSeq = bridgeEvents.lastSeq();
|
|
2024
|
-
// 面板声明它要看哪些会话(数组;空 = 关掉了)。据此对齐订阅 + 判断"面板在不在看"
|
|
2025
|
-
// (授权拦截只在面板看着的时候才抢答,否则一律交给官方链路)。
|
|
2026
|
-
const watch = Array.isArray(body.watch) ? body.watch.filter((id) => typeof id === 'string' && id !== '') : [];
|
|
2027
|
-
bridgeWatch.ids = watch.slice(0, 4);
|
|
2028
|
-
bridgeApprovalsUi = body.approvalsUi === true;
|
|
2029
|
-
if (bridgeApprovalsUi !== bridgeApprovalsUiLogged) {
|
|
2030
|
-
bridgeApprovalsUiLogged = bridgeApprovalsUi;
|
|
2031
|
-
bridgeDiag('approvalsUi → ' + bridgeApprovalsUi + '(客户端可否显示授权卡片)');
|
|
2032
|
-
}
|
|
2033
2259
|
bridgeWatch.at = Date.now();
|
|
2034
|
-
bridgeThread.sync(watchedSessions());
|
|
2035
2260
|
// 看护状态变化就写一行;并且**首帧必写**(0.3.31:只记"翻转"会让"一直没人看"这种情况在日志里
|
|
2036
2261
|
// 完全空白,事后无从判断 —— 上一轮就是这样被拖了一整轮)。
|
|
2037
2262
|
const watchingNow = hasWatcher();
|
|
@@ -2039,118 +2264,33 @@ export async function apply(ctx, config) {
|
|
|
2039
2264
|
bridgeWatchLogged = watchingNow;
|
|
2040
2265
|
bridgeWatchWroteFirst = true;
|
|
2041
2266
|
bridgeDiag('hasWatcher → ' + watchingNow
|
|
2042
|
-
+ ' (
|
|
2043
|
-
+ ' approvalsUi=' + bridgeApprovalsUi + ')');
|
|
2267
|
+
+ ' (对话框=[' + bridgeWatch.dialogIds.join(',') + '])');
|
|
2044
2268
|
}
|
|
2045
2269
|
// 请求过对话框、客户端半部却从没来问过 → 说清楚原因(多半是浏览器还缓存着旧的客户端 bundle)。
|
|
2046
2270
|
if (askDialog.open && !askDialogLive() && askDialog.warned !== true) {
|
|
2047
2271
|
askDialog.warned = true;
|
|
2048
2272
|
console.warn('[code-server] 请求了「问 DSH」对话框,但客户端半部没有轮询 /ask/state:'
|
|
2049
|
-
+ '浏览器可能还在用旧的客户端 bundle(硬刷新 / 清 Code Cache)。'
|
|
2050
|
-
+ '此期间提问会退回编辑器面板,授权也在面板里批。');
|
|
2273
|
+
+ '浏览器可能还在用旧的客户端 bundle(硬刷新 / 清 Code Cache)。');
|
|
2051
2274
|
}
|
|
2052
|
-
//
|
|
2053
|
-
//
|
|
2054
|
-
//
|
|
2055
|
-
const threadRev = bridgeThread.rev(watchedSessions());
|
|
2056
|
-
const snapshot = bridgeThread.supported()
|
|
2057
|
-
? bridgeThread.snapshot(watchedSessions())
|
|
2058
|
-
: { available: false, sessionId: null, cursor: null, rev: 0, entries: [], error: '宿主没有 sessionController.follow(DSH 版本过旧)' };
|
|
2059
|
-
// **按"客户端已经拿到哪一版"决定发不发**(0.3.29 修):0.3.28 比的是宿主自己上次发过的版本,
|
|
2060
|
-
// 于是"面板是后开的"这种情形下宿主认为没变化、什么都不发,面板手里一份快照都没有 ⇒ 旧模型把它
|
|
2061
|
-
// 当成"宿主没有对话流能力"(用户实测到的那条误报)。现在由客户端用 threadRev 声明自己持有的版本;
|
|
2062
|
-
// 不带这个字段的旧扩展一律收整份(不省流量,但绝不误报)。
|
|
2063
|
-
const peerRev = Number.isSafeInteger(body.threadRev) ? body.threadRev : null;
|
|
2064
|
-
const threadChanged = peerRev === null || peerRev !== threadRev || snapshot.available === false;
|
|
2065
|
-
bridgeThreadRevSent = threadRev;
|
|
2066
|
-
// 对话流走独立字段:每个会话给有界条目(见 lib/bridge-thread.mjs),
|
|
2067
|
-
// 授权待决列表也一并回去(面板显示卡片)。
|
|
2275
|
+
// **对话流不再经这里**(0.3.59):那是编辑器 webview 面板时代的分工 —— 面板退役后这条
|
|
2276
|
+
// 每 600ms 一趟的通道上驮着 ≤1MB 的快照(0.3.27 还专门为它做过"没变化就不重传"的省流量),
|
|
2277
|
+
// 现在全归 `/api/code-server/ask/state`:对话框每 900ms 只问修订号,没变化只回一个数字。
|
|
2068
2278
|
return jsonResponse({
|
|
2069
2279
|
ok: true,
|
|
2070
2280
|
events,
|
|
2071
2281
|
// 事件序号的高水位:扩展用它自查游标是否超前(超前 ⇒ 退回 since=0 重新对齐,避免永久失聪)。
|
|
2072
2282
|
lastSeq,
|
|
2073
|
-
//
|
|
2074
|
-
|
|
2075
|
-
thread: threadChanged ? snapshot : undefined,
|
|
2076
|
-
approvals: bridgeApprovalBoard.snapshot(),
|
|
2077
|
-
// 授权卡片要在面板上倒计时,窗口长度由 host 给(面板不猜)。
|
|
2078
|
-
approvalHoldMs: DEFAULT_HOLD_MS,
|
|
2079
|
-
dshVersion: dshVersion(),
|
|
2080
|
-
// 界面版本:面板据此提示"内置渲染器与当前 DSH 界面版本不一致"。
|
|
2081
|
-
uiVersion: uiVersion(),
|
|
2082
|
-
// 能力探测(0.3.24;0.3.25 起以"客户端半部真的在轮询"为准):
|
|
2083
|
-
// 只有对话框活着的时候才让扩展走 ask-open;否则扩展开编辑器面板(那里也能批授权)。
|
|
2283
|
+
// 能力探测(0.3.24;0.3.25 起以"客户端半部真的在轮询 /ask/state"为准):
|
|
2284
|
+
// 只有对话框活着的时候才让扩展走 ask-open;0.3.59 起不满足时扩展给一条提示(不再退编辑器面板)。
|
|
2084
2285
|
askDialog: askDialogLive(),
|
|
2085
2286
|
// 诊断用:请求过对话框但客户端没响应(浏览器缓存旧 bundle?)。
|
|
2086
2287
|
askDialogRequested: askDialog.open,
|
|
2288
|
+
// FIM(实验性)状态与用量:扩展据此决定要不要注册内联补全 provider,并把用量显示在状态栏。
|
|
2289
|
+
// **必须每趟都带**(它是唯一能让用户看到"补全花了多少"的地方 —— 这条调用不进 DSH 的计量)。
|
|
2290
|
+
fim: fimSnapshot(),
|
|
2087
2291
|
});
|
|
2088
2292
|
}
|
|
2089
2293
|
|
|
2090
|
-
async function handleBridgeAsk(request) {
|
|
2091
|
-
const denied = bridgeRejection(request);
|
|
2092
|
-
if (denied !== null) return denied;
|
|
2093
|
-
let body;
|
|
2094
|
-
try {
|
|
2095
|
-
body = await readJsonBody(request);
|
|
2096
|
-
} catch {
|
|
2097
|
-
return jsonResponse({ ok: false, error: '请求体不是合法 JSON' }, 400);
|
|
2098
|
-
}
|
|
2099
|
-
const text = typeof body.text === 'string' ? body.text : '';
|
|
2100
|
-
if (text === '') return jsonResponse({ ok: false, error: '需要 text 字段(要问 DSH 的话)' }, 400);
|
|
2101
|
-
const file = typeof body.file === 'string' && body.file !== '' ? body.file : null;
|
|
2102
|
-
const lineStart = Number.isSafeInteger(body.lineStart) ? body.lineStart : null;
|
|
2103
|
-
const lineEnd = Number.isSafeInteger(body.lineEnd) ? body.lineEnd : null;
|
|
2104
|
-
const selection = typeof body.selection === 'string' && body.selection !== '' ? body.selection : null;
|
|
2105
|
-
// 交给 bridge-session.mjs(agent 投递);它内部特性探测 agents,缺失时返回
|
|
2106
|
-
// {ok:false, code:'NO_AGENT'} —— 这里原样透传,并把 NO_AGENT 映射成 409。
|
|
2107
|
-
const result = await deliverEditorPrompt(ctx, {
|
|
2108
|
-
text,
|
|
2109
|
-
file,
|
|
2110
|
-
lineStart,
|
|
2111
|
-
lineEnd,
|
|
2112
|
-
selection,
|
|
2113
|
-
languageId: typeof body.languageId === 'string' ? body.languageId : null,
|
|
2114
|
-
});
|
|
2115
|
-
// 会话流 + 授权拦截都在这里建立(只针对**被问过的**那个会话,agent 作用域):
|
|
2116
|
-
// - thread:面板像 DSH 对话那样显示"新内容"(见 lib/bridge-thread.mjs);
|
|
2117
|
-
// - approval:该会话的敏感动作(工作区外写入 / 命令执行)先在面板里问一句,
|
|
2118
|
-
// 8 秒没人答就交给官方链路(见 lib/bridge-approval.mjs)。
|
|
2119
|
-
if (result.ok === true && typeof result.sessionId === 'string' && result.sessionId !== '') {
|
|
2120
|
-
const ok = bridgeThread.watch(result.sessionId);
|
|
2121
|
-
if (!ok) console.warn('[code-server] 编辑器桥:会话订阅未建立(宿主没有 sessionController.follow)');
|
|
2122
|
-
bridgeApproval.intercept(result.agent, result.sessionId);
|
|
2123
|
-
}
|
|
2124
|
-
// `agent` 是内部句柄,不能进 JSON。
|
|
2125
|
-
const { agent: _agent, ...payload } = result;
|
|
2126
|
-
void _agent;
|
|
2127
|
-
return jsonResponse(payload, result.ok === true ? 200 : (result.code === 'NO_AGENT' ? 409 : 200));
|
|
2128
|
-
}
|
|
2129
|
-
|
|
2130
|
-
/**
|
|
2131
|
-
* 面板对授权请求的决策(0.3.22)。
|
|
2132
|
-
*
|
|
2133
|
-
* **这是命名空间里唯一的非只读路由**,约束写死在实现里:
|
|
2134
|
-
* - 只能回答**本 host 发起、仍未决**的请求(单次使用,用后即废);
|
|
2135
|
-
* - outcome 只接受 `allowed-once` / `rejected`;
|
|
2136
|
-
* - 不接受任何自由文本、路径或命令参数 —— 它只能"回答问题",不能"发起动作"。
|
|
2137
|
-
*/
|
|
2138
|
-
async function handleBridgeApprove(request) {
|
|
2139
|
-
const denied = bridgeRejection(request);
|
|
2140
|
-
if (denied !== null) return denied;
|
|
2141
|
-
let body;
|
|
2142
|
-
try {
|
|
2143
|
-
body = await readJsonBody(request);
|
|
2144
|
-
} catch {
|
|
2145
|
-
return jsonResponse({ ok: false, error: '请求体不是合法 JSON' }, 400);
|
|
2146
|
-
}
|
|
2147
|
-
const verdict = answerApproval(
|
|
2148
|
-
typeof body.id === 'string' ? body.id : '',
|
|
2149
|
-
typeof body.outcome === 'string' ? body.outcome : '',
|
|
2150
|
-
);
|
|
2151
|
-
return jsonResponse(verdict.body, verdict.status);
|
|
2152
|
-
}
|
|
2153
|
-
|
|
2154
2294
|
// ---------------------------------------------------------------- 「问 DSH」对话框(client 半部)
|
|
2155
2295
|
|
|
2156
2296
|
/** 对话框每 900ms 问一次:给修订号,没变就只回一个数字(不重传几十 KB 的对话流)。 */
|
|
@@ -2193,6 +2333,9 @@ export async function apply(ctx, config) {
|
|
|
2193
2333
|
if (text === '') return jsonResponse({ ok: false, error: '需要 text 字段(要问 DSH 的话)' }, 400);
|
|
2194
2334
|
const mode = body.mode === 'file' ? 'file' : askDialog.mode;
|
|
2195
2335
|
const context = askContextFromCache(mode);
|
|
2336
|
+
// 投递方式(0.3.59):默认跟着 DSH 自己的设置 `ui-conversation.busyEnter` 走(见
|
|
2337
|
+
// lib/bridge-session.mjs 的 pickBusyEnter)—— 面板按 Enter 与主界面按 Enter 是同一个手势,
|
|
2338
|
+
// 行为必须一致。显式传 `delivery: 'queue'|'steer'` 可覆盖(测试用,或将来在面板上做"插队"按钮)。
|
|
2196
2339
|
const result = await deliverEditorPrompt(ctx, {
|
|
2197
2340
|
text,
|
|
2198
2341
|
file: context === null ? null : context.file,
|
|
@@ -2200,7 +2343,7 @@ export async function apply(ctx, config) {
|
|
|
2200
2343
|
lineEnd: context === null ? null : context.lineEnd,
|
|
2201
2344
|
selection: context === null ? null : context.selection,
|
|
2202
2345
|
languageId: context === null ? null : context.languageId,
|
|
2203
|
-
});
|
|
2346
|
+
}, { delivery: typeof body.delivery === 'string' ? body.delivery : undefined });
|
|
2204
2347
|
if (result.ok === true && typeof result.sessionId === 'string' && result.sessionId !== '') {
|
|
2205
2348
|
askDialog.sessionId = result.sessionId;
|
|
2206
2349
|
askDialog.mode = mode;
|
|
@@ -2213,7 +2356,7 @@ export async function apply(ctx, config) {
|
|
|
2213
2356
|
return jsonResponse(payload, result.ok === true ? 200 : (result.code === 'NO_AGENT' ? 409 : 200));
|
|
2214
2357
|
}
|
|
2215
2358
|
|
|
2216
|
-
/** 对话框回答授权(
|
|
2359
|
+
/** 对话框回答授权(与宿主内部 answerApproval 同一套校验:白名单 outcome + 只认仍未决的 id)。 */
|
|
2217
2360
|
async function handleAskApprove(request) {
|
|
2218
2361
|
let body;
|
|
2219
2362
|
try {
|
|
@@ -2239,15 +2382,6 @@ export async function apply(ctx, config) {
|
|
|
2239
2382
|
return jsonResponse({ ok: true });
|
|
2240
2383
|
}
|
|
2241
2384
|
|
|
2242
|
-
/** 面板产物(thread.js / thread.css)给对话框注入用。 */
|
|
2243
|
-
async function handleAskBundle() {
|
|
2244
|
-
const bundle = askBundle();
|
|
2245
|
-
if (bundle === null) {
|
|
2246
|
-
return jsonResponse({ ok: false, error: '面板产物缺失:请在插件源码里跑 pnpm run build:webview,或重装插件' }, 503);
|
|
2247
|
-
}
|
|
2248
|
-
return jsonResponse({ ok: true, rev: askRevision(), ...bundle });
|
|
2249
|
-
}
|
|
2250
|
-
|
|
2251
2385
|
async function handleBridgeEvent(request) {
|
|
2252
2386
|
const denied = bridgeRejection(request);
|
|
2253
2387
|
if (denied !== null) return denied;
|
|
@@ -2283,7 +2417,7 @@ export async function apply(ctx, config) {
|
|
|
2283
2417
|
{ path: `${API_BASE}/ask/send`, methods: ['POST'], fetch: handleAskSend },
|
|
2284
2418
|
{ path: `${API_BASE}/ask/approve`, methods: ['POST'], fetch: handleAskApprove },
|
|
2285
2419
|
{ path: `${API_BASE}/ask/close`, methods: ['POST'], fetch: handleAskClose },
|
|
2286
|
-
|
|
2420
|
+
// 0.3.59 删掉了 `/ask/bundle`(面板产物不再存在:面板本体在 lib/client.js 里,渲染器走页面模块表)。
|
|
2287
2421
|
// ---- 编辑器桥的 4 条路由**不在 /api 下**(0.3.9 修正)----
|
|
2288
2422
|
// 原因:Connection 给 `/api` 装了 cookie fence(无 cookie → 401),而扩展宿主是 Node 进程、
|
|
2289
2423
|
// 永远拿不到浏览器 cookie ⇒ 挂在这里的路由根本到不了(实测:带令牌也被 401/405 挡回)。
|
|
@@ -2394,3 +2528,30 @@ export async function apply(ctx, config) {
|
|
|
2394
2528
|
|
|
2395
2529
|
console.log(`[code-server] static plugin loaded (serve=${requestedServe()} host=${cfg.host} port=${cfg.port === 0 ? '0=随机' : cfg.port})`);
|
|
2396
2530
|
}
|
|
2531
|
+
|
|
2532
|
+
/**
|
|
2533
|
+
* 仅测试用:把一次桥请求交给**当前实例**的桥分发器,不经过命名管道 / unix socket。
|
|
2534
|
+
*
|
|
2535
|
+
* 为什么需要这条钩子(2026-09-22,0.3.63 CI 红 #2 的根治):本机沙箱里**连接**命名管道会 EPERM
|
|
2536
|
+
* (监听允许、连接不允许),于是 scripts/test-bridge-routes.mjs 里所有走 IPC 的用例在本机一律
|
|
2537
|
+
* SKIP —— "本地 pass=30" 里**不含**桥的状态码断言,`/complete` 的 403 就是这么在本地假绿、
|
|
2538
|
+
* 推到 runner 才红的。传输可以是本机的,断言不能是本机的:有了它,那些断言在任何机器上都真跑。
|
|
2539
|
+
*
|
|
2540
|
+
* 与真实传输**同构**(逐字段对照 nodeRouteFromFetch):同一个 dispatchBridge、body 同样收成
|
|
2541
|
+
* Buffer、Response 同样摊平成 {status, headers, text}。缺的只有 socket 本身 —— 所以它**不能**
|
|
2542
|
+
* 替代"监听口能起来"这条验证,那一条仍由真实运行的 IPC 覆盖(test-bridge-routes 里那条
|
|
2543
|
+
* 「桥走本机 IPC」的用例走的还是真端点)。
|
|
2544
|
+
*/
|
|
2545
|
+
export async function bridgeRequestForTests({ path, method = 'GET', headers = {}, body = '' } = {}) {
|
|
2546
|
+
if (activeBridgeDispatch === null) throw new Error('bridgeRequestForTests: 还没有 apply() 过,拿不到桥分发器');
|
|
2547
|
+
const response = await activeBridgeDispatch(
|
|
2548
|
+
new URL(path, 'http://127.0.0.1'),
|
|
2549
|
+
String(method).toUpperCase(),
|
|
2550
|
+
// 与 Node 的 IncomingMessage.headers 同形(键全小写):guard 读 origin 与令牌头就靠它。
|
|
2551
|
+
Object.fromEntries(Object.entries(headers).map(([k, v]) => [String(k).toLowerCase(), v])),
|
|
2552
|
+
Buffer.from(body ?? '', 'utf8'),
|
|
2553
|
+
);
|
|
2554
|
+
const flat = {};
|
|
2555
|
+
response.headers.forEach((value, key) => { flat[key] = value; });
|
|
2556
|
+
return { status: response.status, headers: flat, text: await response.text() };
|
|
2557
|
+
}
|