@wenbin_wb/dsh-bridge 2.8.4 → 2.8.6

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/client/index.js CHANGED
@@ -3028,6 +3028,163 @@ function injectMobileStyles() {
3028
3028
  display: none !important;
3029
3029
  }
3030
3030
 
3031
+ /* 3.0 工作区 Workbench / 任务管理 / 多 Tab 栏移动端自适应适配 */
3032
+ body:not(.dsh-workbench-open) div[class*="nArs4W_panel"],
3033
+ body:not(.dsh-workbench-open) div[class*="workbench_panel"],
3034
+ body:not(.dsh-workbench-open) div[class*="workbenchPanel"],
3035
+ div[class*="nArs4W_panel"][class*="panelHidden"],
3036
+ div[class*="workbench_panel"][class*="panelHidden"],
3037
+ div[class*="workbenchPanel"][class*="panelHidden"],
3038
+ div[class*="panelHidden"] {
3039
+ display: none !important;
3040
+ visibility: hidden !important;
3041
+ pointer-events: none !important;
3042
+ width: 0 !important;
3043
+ height: 0 !important;
3044
+ max-height: 0 !important;
3045
+ z-index: -1 !important;
3046
+ opacity: 0 !important;
3047
+ transform: translateX(105%) !important;
3048
+ }
3049
+
3050
+ body.dsh-workbench-open div[class*="nArs4W_panel"]:not([class*="panelHidden"]),
3051
+ body.dsh-workbench-open div[class*="workbench_panel"]:not([class*="panelHidden"]),
3052
+ body.dsh-workbench-open div[class*="workbenchPanel"]:not([class*="panelHidden"]) {
3053
+ display: flex !important;
3054
+ visibility: visible !important;
3055
+ pointer-events: auto !important;
3056
+ top: var(--dsh-mobile-header-h, 52px) !important;
3057
+ height: calc(100dvh - var(--dsh-mobile-header-h, 52px)) !important;
3058
+ max-height: calc(100dvh - var(--dsh-mobile-header-h, 52px)) !important;
3059
+ z-index: 50 !important;
3060
+ box-sizing: border-box !important;
3061
+ background: var(--dsw-alias-bg-layer-1, #ffffff) !important;
3062
+ transform: none !important;
3063
+ opacity: 1 !important;
3064
+ }
3065
+
3066
+ /* Tab 栏:横向滑动手势 + 干净的底部边框,杜绝与顶部移动端 Header 重叠 */
3067
+ div[class*="nArs4W_tabBar"],
3068
+ div[class*="workbench_tabBar"],
3069
+ div[class*="tabBar"] {
3070
+ min-height: 40px !important;
3071
+ height: 40px !important;
3072
+ background: var(--dsw-alias-bg-layer-1, #ffffff) !important;
3073
+ border-bottom: 1px solid rgba(0, 0, 0, 0.08) !important;
3074
+ display: flex !important;
3075
+ align-items: center !important;
3076
+ justify-content: space-between !important;
3077
+ padding: 0 8px !important;
3078
+ gap: 6px !important;
3079
+ overflow: visible !important;
3080
+ box-sizing: border-box !important;
3081
+ }
3082
+
3083
+ div[class*="nArs4W_tabList"],
3084
+ div[class*="tabList"] {
3085
+ display: flex !important;
3086
+ align-items: center !important;
3087
+ gap: 6px !important;
3088
+ flex: 1 1 auto !important;
3089
+ min-width: 0 !important;
3090
+ overflow-x: auto !important;
3091
+ scrollbar-width: none !important;
3092
+ -webkit-overflow-scrolling: touch !important;
3093
+ }
3094
+ div[class*="nArs4W_tabList"]::-webkit-scrollbar,
3095
+ div[class*="tabList"]::-webkit-scrollbar {
3096
+ display: none !important;
3097
+ }
3098
+
3099
+ /* 单个 Tab 胶囊化,文字超长自动打点,防止 Tab 互相挤压 */
3100
+ div[class*="nArs4W_tab"],
3101
+ div[class*="workbench_tab"] {
3102
+ flex: 0 0 auto !important;
3103
+ max-width: 170px !important;
3104
+ min-width: 70px !important;
3105
+ height: 30px !important;
3106
+ padding: 0 8px 0 10px !important;
3107
+ border-radius: 6px !important;
3108
+ font-size: 12.5px !important;
3109
+ display: inline-flex !important;
3110
+ align-items: center !important;
3111
+ justify-content: space-between !important;
3112
+ gap: 6px !important;
3113
+ background: var(--dsw-alias-bg-layer-2, #f3f4f6) !important;
3114
+ color: var(--dsw-alias-label-secondary, #6b7280) !important;
3115
+ cursor: pointer !important;
3116
+ user-select: none !important;
3117
+ box-sizing: border-box !important;
3118
+ }
3119
+
3120
+ div[class*="nArs4W_tabActive"],
3121
+ div[class*="workbench_tabActive"] {
3122
+ background: var(--dsw-alias-bg-layer-1, #ffffff) !important;
3123
+ color: var(--dsw-alias-label-primary, #111827) !important;
3124
+ font-weight: 600 !important;
3125
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1) !important;
3126
+ }
3127
+
3128
+ span[class*="nArs4W_tabTitle"],
3129
+ span[class*="tabTitle"] {
3130
+ overflow: hidden !important;
3131
+ text-overflow: ellipsis !important;
3132
+ white-space: nowrap !important;
3133
+ flex: 1 1 auto !important;
3134
+ }
3135
+
3136
+ button[class*="nArs4W_tabClose"],
3137
+ button[class*="tabClose"] {
3138
+ width: 18px !important;
3139
+ height: 18px !important;
3140
+ border-radius: 50% !important;
3141
+ display: inline-flex !important;
3142
+ align-items: center !important;
3143
+ justify-content: center !important;
3144
+ flex-shrink: 0 !important;
3145
+ opacity: 0.6 !important;
3146
+ padding: 0 !important;
3147
+ }
3148
+
3149
+ button[class*="nArs4W_tabBarPlus"],
3150
+ button[class*="tabBarPlus"] {
3151
+ width: 28px !important;
3152
+ height: 28px !important;
3153
+ border-radius: 50% !important;
3154
+ display: inline-flex !important;
3155
+ align-items: center !important;
3156
+ justify-content: center !important;
3157
+ flex-shrink: 0 !important;
3158
+ }
3159
+
3160
+ /* 移动端面板右上角“返回对话 / ✕ 收起”按钮:常驻右侧,醒目且易触达 */
3161
+ .dsh-mobile-panel-close-btn {
3162
+ margin-left: 8px !important;
3163
+ flex: 0 0 auto !important;
3164
+ height: 28px !important;
3165
+ padding: 0 10px !important;
3166
+ border-radius: 14px !important;
3167
+ font-size: 12px !important;
3168
+ font-weight: 600 !important;
3169
+ background: #2563eb !important;
3170
+ color: #ffffff !important;
3171
+ border: none !important;
3172
+ display: inline-flex !important;
3173
+ align-items: center !important;
3174
+ justify-content: center !important;
3175
+ gap: 4px !important;
3176
+ cursor: pointer !important;
3177
+ user-select: none !important;
3178
+ box-shadow: 0 2px 6px rgba(37, 99, 235, 0.28) !important;
3179
+ white-space: nowrap !important;
3180
+ transition: transform 0.1s, opacity 0.15s !important;
3181
+ z-index: 10 !important;
3182
+ }
3183
+ .dsh-mobile-panel-close-btn:active {
3184
+ transform: scale(0.95) !important;
3185
+ opacity: 0.85 !important;
3186
+ }
3187
+
3031
3188
  /* 3.1 会话对话头部顶栏:移动端防挤压与空间释放优化(严格排除 .dsh-mobile-app-header) */
3032
3189
  div[class*="_centerCol"] header,
3033
3190
  header[class*="wSkVaW_header"] {
@@ -3222,19 +3379,53 @@ function injectMobileStyles() {
3222
3379
  transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
3223
3380
  overflow-y: auto !important;
3224
3381
  border-right: 1px solid rgba(0, 0, 0, 0.06) !important;
3382
+ pointer-events: auto !important;
3225
3383
  }
3226
3384
  body.dsh-drawer-open div[class*="_sidebarCol"] {
3227
3385
  transform: translateX(0) !important;
3228
3386
  box-shadow: 4px 0 28px rgba(0, 0, 0, 0.25) !important;
3387
+ pointer-events: auto !important;
3229
3388
  }
3230
3389
 
3231
- /* 抽屉内部:强制 100% 宽度,隐藏冗余折叠按钮 */
3390
+ /* 抽屉内部:强制 100% 宽度,无论内部状态如何均正常展开并展示 DSH 自带的顶部收起侧边栏图标 */
3232
3391
  body.dsh-drawer-open div[class*="hHd-Xa_root"] {
3233
3392
  width: 100% !important;
3234
3393
  max-width: 100% !important;
3394
+ min-width: 100% !important;
3395
+ display: flex !important;
3396
+ flex-direction: column !important;
3397
+ }
3398
+ body.dsh-drawer-open div[class*="hHd-Xa_collapsed"] div[class*="hHd-Xa_regionArea"],
3399
+ body.dsh-drawer-open div[class*="hHd-Xa_collapsed"] button[class*="hHd-Xa_newSession"],
3400
+ body.dsh-drawer-open div[class*="hHd-Xa_collapsed"] div[class*="qDHVXG_root"] {
3401
+ display: flex !important;
3402
+ visibility: visible !important;
3403
+ }
3404
+ div[class*="hHd-Xa_logoRow"] {
3405
+ display: flex !important;
3406
+ align-items: center !important;
3407
+ justify-content: space-between !important;
3408
+ width: 100% !important;
3409
+ padding: 10px 14px 6px 14px !important;
3410
+ box-sizing: border-box !important;
3235
3411
  }
3236
3412
  div[class*="hHd-Xa_logoRow"] button[class*="hHd-Xa_toggle"] {
3237
- display: none !important;
3413
+ display: inline-flex !important;
3414
+ align-items: center !important;
3415
+ justify-content: center !important;
3416
+ width: 32px !important;
3417
+ height: 32px !important;
3418
+ border-radius: 8px !important;
3419
+ color: var(--dsw-alias-label-secondary, #6b7280) !important;
3420
+ background: transparent !important;
3421
+ border: none !important;
3422
+ cursor: pointer !important;
3423
+ margin-left: auto !important;
3424
+ transition: background 0.15s, color 0.15s !important;
3425
+ }
3426
+ div[class*="hHd-Xa_logoRow"] button[class*="hHd-Xa_toggle"]:active {
3427
+ background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.06)) !important;
3428
+ color: var(--dsw-alias-label-primary, #111827) !important;
3238
3429
  }
3239
3430
 
3240
3431
  /* 设置弹窗打开时解除抽屉隐藏限制 */
@@ -3252,8 +3443,6 @@ function injectMobileStyles() {
3252
3443
  position: fixed;
3253
3444
  inset: 0;
3254
3445
  background: rgba(0, 0, 0, 0.4);
3255
- backdrop-filter: blur(4px);
3256
- -webkit-backdrop-filter: blur(4px);
3257
3446
  z-index: 9999;
3258
3447
  display: none !important;
3259
3448
  }
@@ -3505,7 +3694,8 @@ function injectMobileStyles() {
3505
3694
 
3506
3695
  @media (min-width: 769px) {
3507
3696
  .dsh-mobile-app-header,
3508
- .dsh-mobile-backdrop {
3697
+ .dsh-mobile-backdrop,
3698
+ .dsh-mobile-panel-close-btn {
3509
3699
  display: none !important;
3510
3700
  }
3511
3701
  }
@@ -3534,18 +3724,23 @@ function setupMobileExperience(rpcCall, ctx) {
3534
3724
  <line x1="3" y1="15" x2="14" y2="15"></line>
3535
3725
  </svg>
3536
3726
  `;
3537
- leftBtn.onclick = () => {
3727
+ leftBtn.onclick = (e) => {
3728
+ e.stopPropagation();
3538
3729
  const isOpen = document.body.classList.toggle('dsh-drawer-open');
3539
3730
  if (isOpen) {
3540
- const collapsedToggle = document.querySelector('div[class*="hHd-Xa_collapsed"] button[class*="hHd-Xa_toggle"]');
3541
- if (collapsedToggle) collapsedToggle.click();
3731
+ const expand = document.querySelector('button[aria-label*="打开侧边栏"], button[title*="打开侧边栏"]');
3732
+ if (expand) expand.click();
3542
3733
  }
3543
3734
  };
3544
3735
 
3545
- // 中间动态会话标题 (居中展示当前会话名称,避免顶部留白)
3736
+ // 中间动态会话标题 (居中展示当前会话名称,点击可快速切回对话流)
3546
3737
  titleEl = document.createElement('div');
3547
3738
  titleEl.className = 'dsh-mobile-header-title';
3548
3739
  titleEl.innerText = '新会话';
3740
+ titleEl.onclick = () => {
3741
+ const openPanels = document.querySelectorAll('div[class*="nArs4W_panel"]:not([class*="panelHidden"]), div[class*="workbench_panel"]:not([class*="panelHidden"])');
3742
+ openPanels.forEach((p) => p.classList.add('nArs4W_panelHidden'));
3743
+ };
3549
3744
 
3550
3745
  // 右侧 (+) 新建会话按钮 (DeepSeek App 圆形加号风格)
3551
3746
  const rightBtn = document.createElement('button');
@@ -3559,6 +3754,8 @@ function setupMobileExperience(rpcCall, ctx) {
3559
3754
  </svg>
3560
3755
  `;
3561
3756
  rightBtn.onclick = () => {
3757
+ const openPanels = document.querySelectorAll('div[class*="nArs4W_panel"]:not([class*="panelHidden"]), div[class*="workbench_panel"]:not([class*="panelHidden"])');
3758
+ openPanels.forEach((p) => p.classList.add('nArs4W_panelHidden'));
3562
3759
  const dshNewBtn = document.querySelector('button[aria-label="新建会话"]');
3563
3760
  if (dshNewBtn) dshNewBtn.click();
3564
3761
  };
@@ -3587,6 +3784,67 @@ function setupMobileExperience(rpcCall, ctx) {
3587
3784
  if (typeof ctx?.sessions?.list?.subscribe === 'function') {
3588
3785
  ctx.sessions.list.subscribe(syncMobileTitle);
3589
3786
  }
3787
+ if (typeof ctx?.sessions?.active?.subscribe === 'function') {
3788
+ ctx.sessions.active.subscribe(() => {
3789
+ if (document.body.classList.contains('dsh-drawer-open')) {
3790
+ document.body.classList.remove('dsh-drawer-open');
3791
+ }
3792
+ // 仅在移动端切换会话时自动收起右侧面板回到对话(PC端绝不干扰)
3793
+ if (typeof window !== 'undefined' && window.innerWidth <= 768) {
3794
+ document.body.classList.remove('dsh-workbench-open');
3795
+ const openPanels = document.querySelectorAll('div[class*="nArs4W_panel"]:not([class*="panelHidden"]), div[class*="workbench_panel"]:not([class*="panelHidden"])');
3796
+ openPanels.forEach((p) => p.classList.add('nArs4W_panelHidden'));
3797
+ }
3798
+ });
3799
+ }
3800
+
3801
+ // 1.1 移动端右侧边栏 / Workbench 面板管理:仅在移动端(<= 768px)挂载“返回对话”收起按钮,PC端保持纯净
3802
+ const ensurePanelCloseButton = () => {
3803
+ if (typeof window === 'undefined') return;
3804
+ if (window.innerWidth > 768) {
3805
+ document.querySelectorAll('.dsh-mobile-panel-close-btn').forEach(btn => btn.remove());
3806
+ return;
3807
+ }
3808
+ const panels = document.querySelectorAll('div[class*="nArs4W_panel"]:not([class*="panelHidden"]), div[class*="workbench_panel"]:not([class*="panelHidden"])');
3809
+ panels.forEach((p) => {
3810
+ const bar = p.querySelector('div[class*="tabBar"], div[class*="nArs4W_tabBar"]');
3811
+ if (bar && !bar.querySelector('.dsh-mobile-panel-close-btn')) {
3812
+ const btn = document.createElement('button');
3813
+ btn.className = 'dsh-mobile-panel-close-btn';
3814
+ btn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg><span>返回对话</span>`;
3815
+ btn.onclick = (e) => {
3816
+ e.stopPropagation();
3817
+ document.body.classList.remove('dsh-workbench-open');
3818
+ p.classList.add('nArs4W_panelHidden');
3819
+ const collapseBtn = document.querySelector('button[class*="toggleButton"][aria-label*="收起"]');
3820
+ if (collapseBtn) collapseBtn.click();
3821
+ };
3822
+ bar.appendChild(btn);
3823
+ }
3824
+ });
3825
+ };
3826
+
3827
+ const panelObserver = new MutationObserver(ensurePanelCloseButton);
3828
+ panelObserver.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
3829
+ window.addEventListener('resize', ensurePanelCloseButton);
3830
+
3831
+ // 移动端点击面板/工作区触发按钮时,自动激活 dsh-workbench-open
3832
+ document.addEventListener('click', (e) => {
3833
+ if (typeof window === 'undefined' || window.innerWidth > 768) return;
3834
+ const trigger = e.target.closest('button[aria-label*="面板"], button[aria-label*="工作区"], div[class*="toggleCluster"] button, button[class*="subagent"], div[class*="headerActions"] button, div[class*="titleRow"] button');
3835
+ if (trigger && !trigger.classList.contains('dsh-mobile-panel-close-btn') && !trigger.classList.contains('dsh-header-menu-btn') && !trigger.classList.contains('dsh-header-new-btn')) {
3836
+ document.body.classList.add('dsh-workbench-open');
3837
+ }
3838
+ }, true);
3839
+
3840
+ // 移动端点击 DSH 自带的收起侧边栏图标时,自动收起抽屉
3841
+ document.addEventListener('click', (e) => {
3842
+ if (typeof window === 'undefined' || window.innerWidth > 768) return;
3843
+ const toggle = e.target.closest('button[aria-label*="收起侧边栏"], button[title*="收起侧边栏"]');
3844
+ if (toggle) {
3845
+ document.body.classList.remove('dsh-drawer-open');
3846
+ }
3847
+ }, true);
3590
3848
 
3591
3849
  // 2. 创建背景遮罩(用于抽屉侧边栏)
3592
3850
  let backdrop = document.querySelector('.dsh-mobile-backdrop');
@@ -3599,7 +3857,7 @@ function setupMobileExperience(rpcCall, ctx) {
3599
3857
  document.body.appendChild(backdrop);
3600
3858
  }
3601
3859
 
3602
- // 3. 点击抽屉内的会话项自动收起抽屉(排除搜索、工作区、操作菜单、设置、输入等交互)
3860
+ // 3. 点击遮罩收起抽屉,或点击抽屉内的会话项自动收起抽屉
3603
3861
  let lastLongPressTime = 0;
3604
3862
 
3605
3863
  document.addEventListener('click', (e) => {
@@ -3647,11 +3905,17 @@ function setupMobileExperience(rpcCall, ctx) {
3647
3905
  }
3648
3906
  }
3649
3907
 
3650
- // 如果点击的是会话列表项本身(切换到该会话)
3651
- const sessionRow = e.target.closest('div[class*="sessionRow"], div[role="treeitem"]');
3908
+ // 点击会话项后平滑收起抽屉
3909
+ const sessionRow = e.target.closest('a, div[class*="sessionRow"], div[role="treeitem"]');
3652
3910
  if (sessionRow) {
3653
- document.body.classList.remove('dsh-drawer-open');
3911
+ setTimeout(() => {
3912
+ if (document.body.classList.contains('dsh-drawer-open')) {
3913
+ document.body.classList.remove('dsh-drawer-open');
3914
+ }
3915
+ }, 80);
3654
3916
  }
3917
+ } else if (e.target.classList?.contains('dsh-mobile-backdrop')) {
3918
+ document.body.classList.remove('dsh-drawer-open');
3655
3919
  }
3656
3920
  }, true);
3657
3921
 
@@ -4346,32 +4610,7 @@ function RemoteDirectoryFlow(props) {
4346
4610
  outcome.current = props;
4347
4611
  const armed = React.useRef(false);
4348
4612
 
4349
- React.useEffect(() => {
4350
- if (!open) {
4351
- armed.current = false;
4352
- return;
4353
- }
4354
- if (armed.current) return;
4355
- armed.current = true;
4356
-
4357
- // 1. 本机电脑访问(Localhost / 127.0.0.1 / Electron 客户端):直接唤起系统原生文件夹选择对话框!
4358
- if (isLocalEnvironment()) {
4359
- const pickFn = typeof pick === 'function' ? pick : (typeof window.__dshClientCtx?.workspaces?.pickDirectory === 'function' ? () => window.__dshClientCtx.workspaces.pickDirectory() : null);
4360
- if (pickFn) {
4361
- pickFn().then((chosenPath) => {
4362
- if (chosenPath === null) {
4363
- if (outcome.current?.onCancel) outcome.current.onCancel();
4364
- } else if (chosenPath) {
4365
- if (outcome.current?.onPicked) outcome.current.onPicked(chosenPath);
4366
- }
4367
- }, (err) => {
4368
- if (outcome.current?.onError) outcome.current.onError(err instanceof Error ? err.message : String(err));
4369
- });
4370
- return;
4371
- }
4372
- }
4373
-
4374
- // 2. 远程或移动端访问(手机或局域网跨设备/公网):呼出网页版远程目录树形选择器!
4613
+ const openRemoteModal = () => {
4375
4614
  if (typeof window.__dshOpenRemoteWorkspaceModal === 'function') {
4376
4615
  window.__dshOpenRemoteWorkspaceModal(
4377
4616
  (res) => {
@@ -4390,7 +4629,48 @@ function RemoteDirectoryFlow(props) {
4390
4629
  }
4391
4630
  }
4392
4631
  );
4632
+ } else if (outcome.current?.onCancel) {
4633
+ outcome.current.onCancel();
4393
4634
  }
4635
+ };
4636
+
4637
+ React.useEffect(() => {
4638
+ if (!open) {
4639
+ armed.current = false;
4640
+ return;
4641
+ }
4642
+ if (armed.current) return;
4643
+ armed.current = true;
4644
+
4645
+ // 1. 本机电脑或 Electron 环境:尝试唤起原生文件夹选择对话框
4646
+ const isNativeHost = typeof window !== 'undefined' && (window.electron || window.__DSH_NATIVE_HOST__);
4647
+ if (isNativeHost || isLocalEnvironment()) {
4648
+ const pickFn = typeof pick === 'function' ? pick : (typeof window.__dshClientCtx?.workspaces?.pickDirectory === 'function' ? () => window.__dshClientCtx.workspaces.pickDirectory() : null);
4649
+ if (pickFn) {
4650
+ try {
4651
+ const promise = pickFn();
4652
+ if (promise && typeof promise.then === 'function') {
4653
+ promise.then((chosenPath) => {
4654
+ if (chosenPath === null) {
4655
+ if (outcome.current?.onCancel) outcome.current.onCancel();
4656
+ } else if (chosenPath) {
4657
+ if (outcome.current?.onPicked) outcome.current.onPicked(chosenPath);
4658
+ }
4659
+ }).catch(() => {
4660
+ // 原生选择器在纯网页模式下报 needs the native capability,平滑降级至远程目录树选择器
4661
+ openRemoteModal();
4662
+ });
4663
+ return;
4664
+ }
4665
+ } catch {
4666
+ openRemoteModal();
4667
+ return;
4668
+ }
4669
+ }
4670
+ }
4671
+
4672
+ // 2. 远程或移动端访问(手机或局域网跨设备/公网):呼出网页版远程目录树形选择器!
4673
+ openRemoteModal();
4394
4674
  }, [open, pick]);
4395
4675
 
4396
4676
  return null;
@@ -0,0 +1,134 @@
1
+ # Fix: 自建隧道 SSE 504 超时 & 会话列表不显示
2
+
3
+ ## 问题现象
4
+
5
+ 通过自建 WebSocket 隧道(`wss://` 中转服务器)远程访问 DSH 时,出现两个问题:
6
+
7
+ 1. **`/plugins/events` SSE 端点返回 504 超时** — HMR 插件的 Server-Sent Events 连接永远不结束,隧道服务器超时后返回 504
8
+ 2. **Web 侧边栏会话列表不显示** — 页面其他功能正常,唯独会话列表空白
9
+
10
+ ## 根因分析
11
+
12
+ ### 问题 1:SSE 504 超时
13
+
14
+ 隧道协议将 HTTP 请求/响应封装为 WebSocket 消息:tunnel client 向本地 DSH 发起 HTTP 请求,**缓冲完整响应**后再通过 WebSocket 回传给 tunnel server,再由 tunnel server 转发给浏览器。
15
+
16
+ SSE(`text/event-stream`)响应是**永不结束**的流式连接 — 服务器持续推送事件,不调用 `res.end()`。原代码的 `res.on('end')` 回调永远不会触发,tunnel server 的超时(30s 非 API 路径 / 120s API 路径)到期后返回 504。
17
+
18
+ ### 问题 2:会话列表不显示
19
+
20
+ DSH 的 `session.list` RPC 返回所有会话的完整投影数据。在会话数量较多时(233 个会话),响应体高达 **63MB**,其中:
21
+
22
+ | 字段 | 大小 | 占比 |
23
+ |------|------|------|
24
+ | `contextHeaders` | 57.4 MB | 92% |
25
+ | `contextTimeline` | 5.0 MB | 8% |
26
+ | 其他投影字段 | ~0.2 MB | <1% |
27
+
28
+ `contextHeaders` 包含每个会话的完整对话上下文头(系统提示、消息头等),侧边栏列表**完全不需要**这些数据 — 打开会话时通过 WebSocket 实时获取即可。
29
+
30
+ 即使 gzip 压缩后仍有 13.5MB,通过 WebSocket 隧道传输需要 ~33s,超过 DSH 客户端的 `AbortSignal.timeout(30000)` 默认超时,导致请求被中止。
31
+
32
+ ## 修复方案
33
+
34
+ ### 修复 1:SSE 流式响应提前返回
35
+
36
+ ### 修复 1:SSE 流式响应提前返回
37
+
38
+ 在 `_handleHttpRequest()` 中检测 `text/event-stream` content-type,收集初始数据(2 个 chunk 或 500ms 超时)后立即返回响应,不等 `res.end()`:
39
+
40
+ ```javascript
41
+ if (isSSE) {
42
+ let sseSent = false;
43
+ const sseTimer = setTimeout(() => { /* 发送已收集数据 */ }, 500);
44
+ res.on('data', (c) => {
45
+ if (sseSent) return;
46
+ chunks.push(c);
47
+ if (chunks.length >= 2) {
48
+ clearTimeout(sseTimer);
49
+ sseSent = true;
50
+ // 立即发送已收集的初始数据
51
+ this._sendMessage({ type: 'response', ... });
52
+ res.destroy();
53
+ }
54
+ });
55
+ // ... error/end 处理
56
+ return;
57
+ }
58
+ ```
59
+
60
+ ### 修复 2:剥离 session.list 中的大字段
61
+
62
+ 对 `/api/session.list` 的 200 响应,解析 JSON 并删除每个会话投影中的 `contextHeaders` 和 `contextTimeline`:
63
+
64
+ ```javascript
65
+ if (path === '/api/session.list' && res.statusCode === 200) {
66
+ try {
67
+ const json = JSON.parse(bodyBuf.toString('utf8'));
68
+ if (json?.result?.ok && json.result.value?.items) {
69
+ for (const item of json.result.value.items) {
70
+ const proj = item?.projections?.values;
71
+ if (proj) {
72
+ delete proj.contextHeaders;
73
+ delete proj.contextTimeline;
74
+ }
75
+ }
76
+ bodyBuf = Buffer.from(JSON.stringify(json), 'utf8');
77
+ }
78
+ } catch {}
79
+ }
80
+ ```
81
+
82
+ ### 修复 3:大响应 gzip 压缩
83
+
84
+ 对超过 100KB 的可压缩响应(`text/*`、`application/json` 等)自动 gzip 压缩,设置 `content-encoding: gzip` 头:
85
+
86
+ ```javascript
87
+ if (bodyBuf.length > GZIP_THRESHOLD && compressible && !alreadyEncoded) {
88
+ bodyBuf = gzipSync(bodyBuf);
89
+ respHeaders['content-encoding'] = 'gzip';
90
+ respHeaders['content-length'] = String(bodyBuf.length);
91
+ }
92
+ ```
93
+
94
+ ### 修复 4:启用 WebSocket per-message 压缩
95
+
96
+ 将隧道 WebSocket 的 `perMessageDeflate` 从 `false` 改为启用,进一步减少隧道传输量:
97
+
98
+ ```javascript
99
+ this.ws = new WebSocket(url.toString(), {
100
+ handshakeTimeout: 10000,
101
+ perMessageDeflate: {
102
+ clientNoContextTakeover: true,
103
+ serverNoContextTakeover: true,
104
+ clientMaxWindowBits: 15,
105
+ serverMaxWindowBits: 15,
106
+ },
107
+ });
108
+ ```
109
+
110
+ ## 效果
111
+
112
+ | 指标 | 修复前 | 修复后 |
113
+ |------|--------|--------|
114
+ | `session.list` 响应大小 | 63 MB | ~290 KB (剥离后) / 31 KB (gzip 后) |
115
+ | `session.list` 隧道传输时间 | 33s (超时) | 0.74s |
116
+ | `/plugins/events` SSE | 504 超时 | 正常返回初始数据 |
117
+ | 侧边栏会话列表 | 不显示 | 正常显示 |
118
+
119
+ ## 影响范围
120
+
121
+ - **非隧道模式不受影响**:所有修改仅在 `_handleHttpRequest()` 中,只影响自建隧道的 HTTP 代理路径
122
+ - **WebSocket 透传不受影响**:`/api/events.mux` 和 `/api/events.host` 的 WebSocket 升级走独立的 `_handleWsOpen()` 路径,不受此修改影响
123
+ - **非 session.list 请求不受影响**:contextHeaders 剥离仅对 `/api/session.list` 路径生效
124
+ - **小响应不受影响**:gzip 压缩仅对超过 100KB 的可压缩响应生效
125
+ - **已有 content-encoding 的响应不受影响**:不重复压缩
126
+
127
+ ## 测试验证
128
+
129
+ - ✅ 本地 WebSocket 直连 DSH(`/api/events.mux`)正常接收 `session/subscribed` 事件
130
+ - ✅ 通过隧道的 WebSocket(`wss://ds.missus.top/api/events.mux`)正常接收 26+ 条消息
131
+ - ✅ 通过隧道的 `session.list` 请求:31KB / 0.74s(修复前 63MB / 33s 超时)
132
+ - ✅ 响应包含 234 个会话,`contextHeaders` 和 `contextTimeline` 已剥离,`title` 等字段完整
133
+ - ✅ TLS 证书有效,gzip content-encoding 头正确传递
134
+ >>>>>>> pr-21