@qzsy/dsh-bridge 3.3.1 → 3.4.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # 更新日志
2
2
 
3
+ ## [v3.4.0] - 2026-11-02
4
+
5
+ ### ✨ 新增
6
+
7
+ - **移动端模型切换器底部 sheet**:宿主的模型二级菜单从桌面 popover 改成全宽底部 sheet——圆角、抓手、遮罩、滚动区、`safe-area` 底部留白,切换模型更接近原生。
8
+ - **模型 sheet 手势**:点遮罩关闭;在顶部抓手上向下拖关闭(跟手 + 松手吸附);桌面端 popover 完全不变。
9
+ - **兼容有/无搜索框的宿主**:优先用「搜索模型」输入框定位;没有搜索框的移动版本按模型触发链 + 菜单行数兜底,不依赖宿主 CSS Modules 哈希。
10
+
11
+ ### 🧪 验证
12
+
13
+ - 真实 DSH + 无头 Chrome:模型二级菜单变成 390px 全宽、`z=1200`、18px 顶部圆角、遮罩 `z=1190`;底部安全区叠加后内边距 48px;点遮罩 / 顶部下拉均能关闭,7/7 通过。
14
+
15
+
3
16
  ## [v3.3.1] - 2026-11-02
4
17
 
5
18
  ### 🐛 修复
package/README.md CHANGED
@@ -48,6 +48,7 @@ dsh plugin --profile <name> add @qzsy/dsh-bridge
48
48
 
49
49
  - 内容区占满屏宽,左上角「菜单」按钮打开会话 / 工作区 / 插件入口;
50
50
  - 支持手势:左缘向右滑打开、抽屉内向左滑关闭,拖动时跟手,松手吸附;
51
+ - 模型切换器在手机上改为底部 sheet:全宽、抓手、遮罩,支持下拉/点遮罩关闭;
51
52
  - 全面屏自动避开安全区:刘海 / 状态栏 / 底部 home indicator 不遮挡内容;
52
53
  - 点遮罩、点会话行、按 Esc 或转回桌面宽度都会自动收起;
53
54
  - 桌面端保持 DSH 原生布局,不受影响。
package/client/client.js CHANGED
@@ -236,7 +236,8 @@ var MOBILE_DRAWER_CSS = `
236
236
  }
237
237
 
238
238
  .qzsy-mobile-header,
239
- .qzsy-mobile-backdrop {
239
+ .qzsy-mobile-backdrop,
240
+ .qzsy-model-sheet-backdrop {
240
241
  display: none;
241
242
  }
242
243
 
@@ -366,6 +367,109 @@ var MOBILE_DRAWER_CSS = `
366
367
  opacity: 0;
367
368
  pointer-events: none;
368
369
  }
370
+
371
+ /* 5. \u6A21\u578B\u5207\u6362\u5668\uFF1A\u628A\u5BBF\u4E3B\u684C\u9762 popover \u6539\u6210\u79FB\u52A8\u7AEF\u5E95\u90E8 sheet */
372
+ @keyframes qzsy-model-sheet-in {
373
+ from { transform: translateY(100%); opacity: 0.6; }
374
+ to { transform: translateY(0); opacity: 1; }
375
+ }
376
+
377
+ .qzsy-model-sheet-backdrop {
378
+ display: block;
379
+ position: fixed;
380
+ inset: 0;
381
+ z-index: 1190;
382
+ background: rgba(0, 0, 0, 0.4);
383
+ opacity: 0;
384
+ pointer-events: none;
385
+ transition: opacity 240ms ease;
386
+ touch-action: none;
387
+ -webkit-tap-highlight-color: transparent;
388
+ }
389
+ body.qzsy-model-sheet-open .qzsy-model-sheet-backdrop {
390
+ opacity: 1;
391
+ pointer-events: auto;
392
+ }
393
+
394
+ [data-qzsy-model-sheet] {
395
+ position: fixed !important;
396
+ top: auto !important;
397
+ left: 0 !important;
398
+ right: 0 !important;
399
+ bottom: 0 !important;
400
+ width: 100vw !important;
401
+ max-width: 100vw !important;
402
+ height: auto !important;
403
+ max-height: 82vh !important;
404
+ max-height: 82dvh !important;
405
+ margin: 0 !important;
406
+ padding: 18px 0 0 !important;
407
+ border: 0 !important;
408
+ border-radius: 18px 18px 0 0 !important;
409
+ background: var(--dsw-alias-bg-layer-1, #ffffff) !important;
410
+ box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.18) !important;
411
+ box-sizing: border-box !important;
412
+ display: flex !important;
413
+ flex-direction: column !important;
414
+ overflow: hidden !important;
415
+ z-index: 1200 !important;
416
+ transform-origin: bottom center !important;
417
+ animation: qzsy-model-sheet-in 260ms cubic-bezier(0.16, 1, 0.3, 1) !important;
418
+ will-change: transform;
419
+ }
420
+ [data-qzsy-model-sheet]::before {
421
+ content: '';
422
+ position: absolute;
423
+ top: 7px;
424
+ left: 50%;
425
+ width: 36px;
426
+ height: 4px;
427
+ margin-left: -18px;
428
+ border-radius: 999px;
429
+ background: rgba(0, 0, 0, 0.18);
430
+ pointer-events: none;
431
+ }
432
+ [data-qzsy-model-sheet] > div,
433
+ [data-qzsy-model-sheet] div[role="menu"] {
434
+ width: 100% !important;
435
+ max-width: 100% !important;
436
+ border: 0 !important;
437
+ border-radius: 0 !important;
438
+ background: transparent !important;
439
+ box-shadow: none !important;
440
+ box-sizing: border-box !important;
441
+ }
442
+ [data-qzsy-model-sheet] div[role="menu"] {
443
+ max-height: calc(82vh - 40px) !important;
444
+ max-height: calc(82dvh - 40px) !important;
445
+ overflow-y: auto !important;
446
+ overscroll-behavior: contain !important;
447
+ padding: 8px 12px calc(14px + var(--qzsy-safe-bottom, 0px)) !important;
448
+ }
449
+ [data-qzsy-model-sheet] input {
450
+ width: 100% !important;
451
+ box-sizing: border-box !important;
452
+ }
453
+ [data-qzsy-model-sheet] button[role="menuitem"],
454
+ [data-qzsy-model-sheet] [role="option"] {
455
+ width: 100% !important;
456
+ box-sizing: border-box !important;
457
+ }
458
+ [data-qzsy-model-sheet].qzsy-model-sheet-closing {
459
+ transform: translateY(100%) !important;
460
+ opacity: 0 !important;
461
+ transition: transform 220ms ease, opacity 220ms ease !important;
462
+ animation: none !important;
463
+ }
464
+ body.qzsy-model-sheet-dragging [data-qzsy-model-sheet] {
465
+ transform: translateY(var(--qzsy-model-sheet-y, 0px)) !important;
466
+ transition: none !important;
467
+ animation: none !important;
468
+ }
469
+ body.qzsy-model-sheet-dragging .qzsy-model-sheet-backdrop {
470
+ opacity: var(--qzsy-model-sheet-progress, 1) !important;
471
+ transition: none !important;
472
+ }
369
473
  }
370
474
  `;
371
475
  function isElement(value) {
@@ -405,6 +509,11 @@ function setupMobileDrawer() {
405
509
  let disposed = false;
406
510
  let gesture = null;
407
511
  let suppressClickUntil = 0;
512
+ let modelPortal = null;
513
+ let modelBackdrop = null;
514
+ let modelChainUntil = 0;
515
+ let modelDrag = null;
516
+ let modelDragHandlers = null;
408
517
  const ensureElements = () => {
409
518
  if (disposed || !document.body) return;
410
519
  injectStyles();
@@ -484,6 +593,219 @@ function setupMobileDrawer() {
484
593
  if (!disposed && isOpen()) closeDrawer({ restoreFocus: false });
485
594
  }, 60);
486
595
  };
596
+ const bodyPortalOf = (node) => {
597
+ let el = node;
598
+ while (el && el.parentElement && el.parentElement !== document.body) el = el.parentElement;
599
+ return el && el.parentElement === document.body ? el : null;
600
+ };
601
+ const visiblePortal = (el) => {
602
+ if (!el || !el.isConnected) return false;
603
+ const s = getComputedStyle(el);
604
+ if (s.display === "none" || s.visibility === "hidden") return false;
605
+ const r = el.getBoundingClientRect();
606
+ return r.width > 0 && r.height > 0;
607
+ };
608
+ const isModelTrigger = (target) => {
609
+ const el = target?.closest?.('button, [role="menuitem"], [role="button"], a');
610
+ if (!el) return false;
611
+ const label = `${el.getAttribute("aria-label") || ""} ${el.getAttribute("title") || ""} ${el.innerText || ""}`;
612
+ return /模型|Model|model/.test(label);
613
+ };
614
+ const findModelPortal = () => {
615
+ if (!isMobileViewport()) return null;
616
+ const search = document.querySelector(
617
+ 'input[placeholder*="\u641C\u7D22\u6A21\u578B"], input[placeholder*="Search model"], input[aria-label*="\u641C\u7D22\u6A21\u578B"], input[aria-label*="Search model"]'
618
+ );
619
+ if (search) {
620
+ const menu = search.closest('[role="menu"]') || search.closest('[class*="_menu"]');
621
+ const portal = bodyPortalOf(menu || search);
622
+ if (portal && visiblePortal(portal)) {
623
+ const s = getComputedStyle(portal);
624
+ if ((s.position === "fixed" || s.position === "absolute") && portal.getBoundingClientRect().width > 120) return portal;
625
+ }
626
+ }
627
+ if (Date.now() > modelChainUntil) return null;
628
+ const candidates = [];
629
+ for (const el of document.body.children) {
630
+ if (!(el instanceof HTMLElement)) continue;
631
+ if (el === modelBackdrop || el.classList.contains(HEADER_CLASS) || el.classList.contains(BACKDROP_CLASS)) continue;
632
+ if (!visiblePortal(el)) continue;
633
+ const s = getComputedStyle(el);
634
+ if (s.position !== "fixed" && s.position !== "absolute") continue;
635
+ const menu = el.querySelector('[role="menu"], [role="listbox"]');
636
+ if (!menu) continue;
637
+ const text = menu.innerText || "";
638
+ const rows = menu.querySelectorAll('[role="menuitem"], [role="option"], button, a, [tabindex]').length;
639
+ const hasSearch = Boolean(el.querySelector('input[placeholder*="\u641C\u7D22\u6A21\u578B"], input[placeholder*="Search model"]'));
640
+ if (/推理等级|Reasoning effort|推理强度/i.test(text) && rows <= 2) continue;
641
+ const rect = el.getBoundingClientRect();
642
+ const alreadySheet = rect.width >= window.innerWidth - 2 && Math.abs(rect.bottom - window.innerHeight) < 2 && rect.height > 120;
643
+ if (alreadySheet && !hasSearch) continue;
644
+ if (hasSearch || rows > 2) candidates.push({ el, rows, hasSearch });
645
+ }
646
+ candidates.sort((a, b) => Number(b.hasSearch) - Number(a.hasSearch) || b.rows - a.rows);
647
+ return candidates[0]?.el || null;
648
+ };
649
+ const hostBackdropPresent = () => {
650
+ for (const el of document.body.children) {
651
+ if (!(el instanceof HTMLElement)) continue;
652
+ if (el === modelBackdrop || el.classList.contains(HEADER_CLASS) || el.classList.contains(BACKDROP_CLASS)) continue;
653
+ const s = getComputedStyle(el);
654
+ if (s.position !== "fixed") continue;
655
+ const r = el.getBoundingClientRect();
656
+ if (r.width < window.innerWidth - 4 || r.height < window.innerHeight - 4) continue;
657
+ if (Number(s.opacity) < 0.1) continue;
658
+ const bg = s.backgroundColor || "";
659
+ if (!/^rgba?\(/.test(bg) || /rgba\(0,\s*0,\s*0,\s*0\)/.test(bg)) continue;
660
+ if (el.querySelector('[role="menu"]')) continue;
661
+ return true;
662
+ }
663
+ return false;
664
+ };
665
+ const requestHostModelClose = (portal) => {
666
+ if (disposed || !portal || !portal.isConnected) return;
667
+ const PointerCtor = typeof window.PointerEvent === "function" ? window.PointerEvent : window.MouseEvent;
668
+ try {
669
+ document.body.dispatchEvent(new PointerCtor("pointerdown", { bubbles: true, cancelable: true }));
670
+ } catch {
671
+ }
672
+ try {
673
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
674
+ } catch {
675
+ }
676
+ window.setTimeout(() => {
677
+ if (disposed || !portal.isConnected) return;
678
+ const trigger = document.querySelector('button[aria-haspopup="menu"][aria-expanded="true"][aria-label*="\u9009\u62E9\u6A21\u578B"], button[aria-haspopup="menu"][aria-expanded="true"][aria-label*="Select model"]');
679
+ try {
680
+ trigger?.click();
681
+ } catch {
682
+ }
683
+ window.setTimeout(() => {
684
+ if (!disposed && portal.isConnected && modelPortal === portal) clearModelSheet();
685
+ }, 450);
686
+ }, 120);
687
+ };
688
+ const closeModelSheetNow = () => {
689
+ const portal = modelPortal;
690
+ if (!portal) return;
691
+ document.body.classList.remove("qzsy-model-sheet-open");
692
+ document.body.style.removeProperty("--qzsy-model-sheet-y");
693
+ document.body.style.removeProperty("--qzsy-model-sheet-progress");
694
+ requestHostModelClose(portal);
695
+ };
696
+ const closeModelSheetAnimated = () => {
697
+ const portal = modelPortal;
698
+ if (!portal) return;
699
+ portal.classList.add("qzsy-model-sheet-closing");
700
+ document.body.classList.remove("qzsy-model-sheet-open");
701
+ window.setTimeout(() => requestHostModelClose(portal), 180);
702
+ };
703
+ const ensureModelBackdrop = () => {
704
+ if (modelBackdrop || hostBackdropPresent()) return;
705
+ modelBackdrop = document.createElement("div");
706
+ modelBackdrop.className = "qzsy-model-sheet-backdrop";
707
+ modelBackdrop.setAttribute("aria-hidden", "true");
708
+ modelBackdrop.addEventListener("pointerdown", (event) => event.stopPropagation(), true);
709
+ modelBackdrop.addEventListener("click", (event) => {
710
+ event.preventDefault();
711
+ event.stopPropagation();
712
+ closeModelSheetNow();
713
+ });
714
+ document.body.appendChild(modelBackdrop);
715
+ };
716
+ const detachModelDrag = () => {
717
+ if (!modelPortal || !modelDragHandlers) return;
718
+ modelPortal.removeEventListener("touchstart", modelDragHandlers.onStart);
719
+ modelPortal.removeEventListener("touchmove", modelDragHandlers.onMove);
720
+ modelPortal.removeEventListener("touchend", modelDragHandlers.onEnd);
721
+ modelPortal.removeEventListener("touchcancel", modelDragHandlers.onEnd);
722
+ modelDragHandlers = null;
723
+ };
724
+ const attachModelDrag = (portal) => {
725
+ if (!portal || modelDragHandlers) return;
726
+ const onStart = (event) => {
727
+ if (event.touches?.length !== 1) return;
728
+ const touch = event.touches[0];
729
+ const rect = portal.getBoundingClientRect();
730
+ if (touch.clientY - rect.top > 30) return;
731
+ if (event.target.closest?.('input, textarea, select, button, a, [contenteditable="true"]')) return;
732
+ modelDrag = { startY: touch.clientY, startT: Date.now(), dy: 0, active: false, height: rect.height };
733
+ };
734
+ const onMove = (event) => {
735
+ if (!modelDrag) return;
736
+ const touch = event.touches?.[0];
737
+ if (!touch) return;
738
+ const dy = touch.clientY - modelDrag.startY;
739
+ if (!modelDrag.active) {
740
+ if (dy < 6) return;
741
+ modelDrag.active = true;
742
+ document.body.classList.add("qzsy-model-sheet-dragging");
743
+ }
744
+ modelDrag.dy = Math.max(0, dy);
745
+ document.body.style.setProperty("--qzsy-model-sheet-y", `${modelDrag.dy}px`);
746
+ document.body.style.setProperty("--qzsy-model-sheet-progress", String(Math.max(0, 1 - modelDrag.dy / Math.max(1, modelDrag.height))));
747
+ if (event.cancelable) event.preventDefault();
748
+ };
749
+ const onEnd = () => {
750
+ if (!modelDrag) return;
751
+ const drag = modelDrag;
752
+ modelDrag = null;
753
+ if (!drag.active) return;
754
+ document.body.classList.remove("qzsy-model-sheet-dragging");
755
+ document.body.style.removeProperty("--qzsy-model-sheet-y");
756
+ document.body.style.removeProperty("--qzsy-model-sheet-progress");
757
+ const velocity = drag.dy / Math.max(1, Date.now() - drag.startT);
758
+ if (drag.dy > 110 || velocity > 0.5) closeModelSheetAnimated();
759
+ };
760
+ portal.addEventListener("touchstart", onStart, { passive: true });
761
+ portal.addEventListener("touchmove", onMove, { passive: false });
762
+ portal.addEventListener("touchend", onEnd, { passive: true });
763
+ portal.addEventListener("touchcancel", onEnd, { passive: true });
764
+ modelDragHandlers = { onStart, onMove, onEnd };
765
+ };
766
+ const clearModelSheet = () => {
767
+ if (modelPortal) {
768
+ detachModelDrag();
769
+ modelPortal.removeAttribute("data-qzsy-model-sheet");
770
+ modelPortal.classList.remove("qzsy-model-sheet", "qzsy-model-sheet-closing");
771
+ }
772
+ modelPortal = null;
773
+ modelDrag = null;
774
+ document.body?.classList.remove("qzsy-model-sheet-open", "qzsy-model-sheet-dragging");
775
+ document.body?.style.removeProperty("--qzsy-model-sheet-y");
776
+ document.body?.style.removeProperty("--qzsy-model-sheet-progress");
777
+ if (modelBackdrop) {
778
+ modelBackdrop.remove();
779
+ modelBackdrop = null;
780
+ }
781
+ };
782
+ const applyModelSheet = (portal) => {
783
+ if (modelPortal === portal) return;
784
+ clearModelSheet();
785
+ modelPortal = portal;
786
+ portal.setAttribute("data-qzsy-model-sheet", "");
787
+ portal.classList.add("qzsy-model-sheet");
788
+ attachModelDrag(portal);
789
+ ensureModelBackdrop();
790
+ window.requestAnimationFrame(() => {
791
+ if (!disposed && modelPortal === portal) document.body.classList.add("qzsy-model-sheet-open");
792
+ });
793
+ };
794
+ const syncModelSheet = () => {
795
+ if (disposed) return;
796
+ if (!isMobileViewport()) {
797
+ clearModelSheet();
798
+ return;
799
+ }
800
+ if (modelPortal && !visiblePortal(modelPortal)) clearModelSheet();
801
+ const found = findModelPortal();
802
+ if (!found) {
803
+ if (modelPortal?.isConnected) return;
804
+ if (modelPortal) clearModelSheet();
805
+ return;
806
+ }
807
+ if (found !== modelPortal) applyModelSheet(found);
808
+ };
487
809
  const onClick = (event) => {
488
810
  if (Date.now() < suppressClickUntil) {
489
811
  event.preventDefault();
@@ -498,6 +820,11 @@ function setupMobileDrawer() {
498
820
  else openDrawer();
499
821
  return;
500
822
  }
823
+ if (isModelTrigger(target)) {
824
+ modelChainUntil = Date.now() + 6e3;
825
+ window.setTimeout(syncModelSheet, 30);
826
+ window.setTimeout(syncModelSheet, 180);
827
+ }
501
828
  if (!isOpen()) return;
502
829
  if (target.closest(`.${BACKDROP_CLASS}`)) {
503
830
  closeDrawer();
@@ -531,6 +858,7 @@ function setupMobileDrawer() {
531
858
  clearDrag();
532
859
  closeDrawer({ restoreFocus: false });
533
860
  }
861
+ syncModelSheet();
534
862
  };
535
863
  const onVisibilityChange = () => {
536
864
  if (document.visibilityState === "hidden" && isOpen()) {
@@ -538,9 +866,10 @@ function setupMobileDrawer() {
538
866
  clearDrag();
539
867
  closeDrawer({ restoreFocus: false });
540
868
  }
869
+ if (document.visibilityState === "visible") syncModelSheet();
541
870
  };
542
871
  const onTouchStart = (event) => {
543
- if (disposed || !isMobileViewport() || event.touches?.length !== 1) return;
872
+ if (modelPortal || disposed || !isMobileViewport() || event.touches?.length !== 1) return;
544
873
  const touch = event.touches[0];
545
874
  const sidebar = document.querySelector('div[class*="_sidebarCol"]');
546
875
  if (!sidebar) return;
@@ -572,7 +901,7 @@ function setupMobileDrawer() {
572
901
  };
573
902
  };
574
903
  const onTouchMove = (event) => {
575
- if (!gesture || disposed) return;
904
+ if (modelPortal || !gesture || disposed) return;
576
905
  const touch = event.touches?.[0];
577
906
  if (!touch) return;
578
907
  const dx = touch.clientX - gesture.startX;
@@ -620,11 +949,11 @@ function setupMobileDrawer() {
620
949
  window.requestAnimationFrame(clearDrag);
621
950
  };
622
951
  const onTouchEnd = (event) => {
623
- if (!gesture) return;
952
+ if (modelPortal || !gesture) return;
624
953
  finishGesture(event.changedTouches?.[0]);
625
954
  };
626
955
  const onTouchCancel = () => {
627
- if (!gesture) return;
956
+ if (modelPortal || !gesture) return;
628
957
  finishGesture(null);
629
958
  };
630
959
  injectStyles();
@@ -645,9 +974,11 @@ function setupMobileDrawer() {
645
974
  if (!document.querySelector(`.${HEADER_CLASS}`) || !document.querySelector(`.${MENU_CLASS}`) || !document.querySelector(`.${BACKDROP_CLASS}`) || !document.getElementById(MOBILE_DRAWER_CSS_ID)) {
646
975
  ensureElements();
647
976
  }
977
+ syncModelSheet();
648
978
  });
649
979
  observer.observe(document.body, { childList: true });
650
980
  }
981
+ syncModelSheet();
651
982
  syncAria();
652
983
  return () => {
653
984
  disposed = true;
@@ -664,6 +995,7 @@ function setupMobileDrawer() {
664
995
  window.removeEventListener("touchmove", onTouchMove);
665
996
  window.removeEventListener("touchend", onTouchEnd);
666
997
  window.removeEventListener("touchcancel", onTouchCancel);
998
+ clearModelSheet();
667
999
  clearDrag();
668
1000
  document.body?.classList.remove(OPEN_CLASS);
669
1001
  document.body?.classList.remove("qzsy-drawer-ready");
package/client/mobile.js CHANGED
@@ -33,7 +33,8 @@ export const MOBILE_DRAWER_CSS = `
33
33
  }
34
34
 
35
35
  .qzsy-mobile-header,
36
- .qzsy-mobile-backdrop {
36
+ .qzsy-mobile-backdrop,
37
+ .qzsy-model-sheet-backdrop {
37
38
  display: none;
38
39
  }
39
40
 
@@ -163,6 +164,109 @@ export const MOBILE_DRAWER_CSS = `
163
164
  opacity: 0;
164
165
  pointer-events: none;
165
166
  }
167
+
168
+ /* 5. 模型切换器:把宿主桌面 popover 改成移动端底部 sheet */
169
+ @keyframes qzsy-model-sheet-in {
170
+ from { transform: translateY(100%); opacity: 0.6; }
171
+ to { transform: translateY(0); opacity: 1; }
172
+ }
173
+
174
+ .qzsy-model-sheet-backdrop {
175
+ display: block;
176
+ position: fixed;
177
+ inset: 0;
178
+ z-index: 1190;
179
+ background: rgba(0, 0, 0, 0.4);
180
+ opacity: 0;
181
+ pointer-events: none;
182
+ transition: opacity 240ms ease;
183
+ touch-action: none;
184
+ -webkit-tap-highlight-color: transparent;
185
+ }
186
+ body.qzsy-model-sheet-open .qzsy-model-sheet-backdrop {
187
+ opacity: 1;
188
+ pointer-events: auto;
189
+ }
190
+
191
+ [data-qzsy-model-sheet] {
192
+ position: fixed !important;
193
+ top: auto !important;
194
+ left: 0 !important;
195
+ right: 0 !important;
196
+ bottom: 0 !important;
197
+ width: 100vw !important;
198
+ max-width: 100vw !important;
199
+ height: auto !important;
200
+ max-height: 82vh !important;
201
+ max-height: 82dvh !important;
202
+ margin: 0 !important;
203
+ padding: 18px 0 0 !important;
204
+ border: 0 !important;
205
+ border-radius: 18px 18px 0 0 !important;
206
+ background: var(--dsw-alias-bg-layer-1, #ffffff) !important;
207
+ box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.18) !important;
208
+ box-sizing: border-box !important;
209
+ display: flex !important;
210
+ flex-direction: column !important;
211
+ overflow: hidden !important;
212
+ z-index: 1200 !important;
213
+ transform-origin: bottom center !important;
214
+ animation: qzsy-model-sheet-in 260ms cubic-bezier(0.16, 1, 0.3, 1) !important;
215
+ will-change: transform;
216
+ }
217
+ [data-qzsy-model-sheet]::before {
218
+ content: '';
219
+ position: absolute;
220
+ top: 7px;
221
+ left: 50%;
222
+ width: 36px;
223
+ height: 4px;
224
+ margin-left: -18px;
225
+ border-radius: 999px;
226
+ background: rgba(0, 0, 0, 0.18);
227
+ pointer-events: none;
228
+ }
229
+ [data-qzsy-model-sheet] > div,
230
+ [data-qzsy-model-sheet] div[role="menu"] {
231
+ width: 100% !important;
232
+ max-width: 100% !important;
233
+ border: 0 !important;
234
+ border-radius: 0 !important;
235
+ background: transparent !important;
236
+ box-shadow: none !important;
237
+ box-sizing: border-box !important;
238
+ }
239
+ [data-qzsy-model-sheet] div[role="menu"] {
240
+ max-height: calc(82vh - 40px) !important;
241
+ max-height: calc(82dvh - 40px) !important;
242
+ overflow-y: auto !important;
243
+ overscroll-behavior: contain !important;
244
+ padding: 8px 12px calc(14px + var(--qzsy-safe-bottom, 0px)) !important;
245
+ }
246
+ [data-qzsy-model-sheet] input {
247
+ width: 100% !important;
248
+ box-sizing: border-box !important;
249
+ }
250
+ [data-qzsy-model-sheet] button[role="menuitem"],
251
+ [data-qzsy-model-sheet] [role="option"] {
252
+ width: 100% !important;
253
+ box-sizing: border-box !important;
254
+ }
255
+ [data-qzsy-model-sheet].qzsy-model-sheet-closing {
256
+ transform: translateY(100%) !important;
257
+ opacity: 0 !important;
258
+ transition: transform 220ms ease, opacity 220ms ease !important;
259
+ animation: none !important;
260
+ }
261
+ body.qzsy-model-sheet-dragging [data-qzsy-model-sheet] {
262
+ transform: translateY(var(--qzsy-model-sheet-y, 0px)) !important;
263
+ transition: none !important;
264
+ animation: none !important;
265
+ }
266
+ body.qzsy-model-sheet-dragging .qzsy-model-sheet-backdrop {
267
+ opacity: var(--qzsy-model-sheet-progress, 1) !important;
268
+ transition: none !important;
269
+ }
166
270
  }
167
271
  `;
168
272
 
@@ -211,6 +315,11 @@ export function setupMobileDrawer() {
211
315
  let disposed = false;
212
316
  let gesture = null;
213
317
  let suppressClickUntil = 0;
318
+ let modelPortal = null;
319
+ let modelBackdrop = null;
320
+ let modelChainUntil = 0;
321
+ let modelDrag = null;
322
+ let modelDragHandlers = null;
214
323
 
215
324
  const ensureElements = () => {
216
325
  if (disposed || !document.body) return;
@@ -296,6 +405,228 @@ export function setupMobileDrawer() {
296
405
  }, 60);
297
406
  };
298
407
 
408
+ // ---- 模型切换器:宿主桌面 popover → 移动端底部 sheet ----
409
+ const bodyPortalOf = (node) => {
410
+ let el = node;
411
+ while (el && el.parentElement && el.parentElement !== document.body) el = el.parentElement;
412
+ return el && el.parentElement === document.body ? el : null;
413
+ };
414
+
415
+ const visiblePortal = (el) => {
416
+ if (!el || !el.isConnected) return false;
417
+ const s = getComputedStyle(el);
418
+ if (s.display === 'none' || s.visibility === 'hidden') return false;
419
+ const r = el.getBoundingClientRect();
420
+ return r.width > 0 && r.height > 0;
421
+ };
422
+
423
+ const isModelTrigger = (target) => {
424
+ const el = target?.closest?.('button, [role="menuitem"], [role="button"], a');
425
+ if (!el) return false;
426
+ const label = `${el.getAttribute('aria-label') || ''} ${el.getAttribute('title') || ''} ${el.innerText || ''}`;
427
+ return /模型|Model|model/.test(label);
428
+ };
429
+
430
+ const findModelPortal = () => {
431
+ if (!isMobileViewport()) return null;
432
+ const search = document.querySelector(
433
+ 'input[placeholder*="搜索模型"], input[placeholder*="Search model"], input[aria-label*="搜索模型"], input[aria-label*="Search model"]',
434
+ );
435
+ if (search) {
436
+ const menu = search.closest('[role="menu"]') || search.closest('[class*="_menu"]');
437
+ const portal = bodyPortalOf(menu || search);
438
+ if (portal && visiblePortal(portal)) {
439
+ const s = getComputedStyle(portal);
440
+ if ((s.position === 'fixed' || s.position === 'absolute') && portal.getBoundingClientRect().width > 120) return portal;
441
+ }
442
+ }
443
+ // 无搜索框的宿主(如移动端版本)用「模型触发链 + 菜单行数」兜底
444
+ if (Date.now() > modelChainUntil) return null;
445
+ const candidates = [];
446
+ for (const el of document.body.children) {
447
+ if (!(el instanceof HTMLElement)) continue;
448
+ if (el === modelBackdrop || el.classList.contains(HEADER_CLASS) || el.classList.contains(BACKDROP_CLASS)) continue;
449
+ if (!visiblePortal(el)) continue;
450
+ const s = getComputedStyle(el);
451
+ if (s.position !== 'fixed' && s.position !== 'absolute') continue;
452
+ const menu = el.querySelector('[role="menu"], [role="listbox"]');
453
+ if (!menu) continue;
454
+ const text = menu.innerText || '';
455
+ const rows = menu.querySelectorAll('[role="menuitem"], [role="option"], button, a, [tabindex]').length;
456
+ const hasSearch = Boolean(el.querySelector('input[placeholder*="搜索模型"], input[placeholder*="Search model"]'));
457
+ // 排除「模型 / 推理等级」两行的第一层 popover
458
+ if (/推理等级|Reasoning effort|推理强度/i.test(text) && rows <= 2) continue;
459
+ // 已经是全宽底部 sheet 的宿主菜单不再重复改造
460
+ const rect = el.getBoundingClientRect();
461
+ const alreadySheet = rect.width >= window.innerWidth - 2 && Math.abs(rect.bottom - window.innerHeight) < 2 && rect.height > 120;
462
+ if (alreadySheet && !hasSearch) continue;
463
+ if (hasSearch || rows > 2) candidates.push({ el, rows, hasSearch });
464
+ }
465
+ candidates.sort((a, b) => Number(b.hasSearch) - Number(a.hasSearch) || b.rows - a.rows);
466
+ return candidates[0]?.el || null;
467
+ };
468
+
469
+ const hostBackdropPresent = () => {
470
+ for (const el of document.body.children) {
471
+ if (!(el instanceof HTMLElement)) continue;
472
+ if (el === modelBackdrop || el.classList.contains(HEADER_CLASS) || el.classList.contains(BACKDROP_CLASS)) continue;
473
+ const s = getComputedStyle(el);
474
+ if (s.position !== 'fixed') continue;
475
+ const r = el.getBoundingClientRect();
476
+ if (r.width < window.innerWidth - 4 || r.height < window.innerHeight - 4) continue;
477
+ if (Number(s.opacity) < 0.1) continue;
478
+ const bg = s.backgroundColor || '';
479
+ if (!/^rgba?\(/.test(bg) || /rgba\(0,\s*0,\s*0,\s*0\)/.test(bg)) continue;
480
+ if (el.querySelector('[role="menu"]')) continue;
481
+ return true;
482
+ }
483
+ return false;
484
+ };
485
+
486
+ const requestHostModelClose = (portal) => {
487
+ if (disposed || !portal || !portal.isConnected) return;
488
+ const PointerCtor = typeof window.PointerEvent === 'function' ? window.PointerEvent : window.MouseEvent;
489
+ try { document.body.dispatchEvent(new PointerCtor('pointerdown', { bubbles: true, cancelable: true })); } catch { /* 忽略 */ }
490
+ try { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); } catch { /* 忽略 */ }
491
+ window.setTimeout(() => {
492
+ if (disposed || !portal.isConnected) return;
493
+ const trigger = document.querySelector('button[aria-haspopup="menu"][aria-expanded="true"][aria-label*="选择模型"], button[aria-haspopup="menu"][aria-expanded="true"][aria-label*="Select model"]');
494
+ try { trigger?.click(); } catch { /* 忽略 */ }
495
+ window.setTimeout(() => {
496
+ if (!disposed && portal.isConnected && modelPortal === portal) clearModelSheet();
497
+ }, 450);
498
+ }, 120);
499
+ };
500
+
501
+ const closeModelSheetNow = () => {
502
+ const portal = modelPortal;
503
+ if (!portal) return;
504
+ document.body.classList.remove('qzsy-model-sheet-open');
505
+ document.body.style.removeProperty('--qzsy-model-sheet-y');
506
+ document.body.style.removeProperty('--qzsy-model-sheet-progress');
507
+ requestHostModelClose(portal);
508
+ };
509
+
510
+ const closeModelSheetAnimated = () => {
511
+ const portal = modelPortal;
512
+ if (!portal) return;
513
+ portal.classList.add('qzsy-model-sheet-closing');
514
+ document.body.classList.remove('qzsy-model-sheet-open');
515
+ window.setTimeout(() => requestHostModelClose(portal), 180);
516
+ };
517
+
518
+ const ensureModelBackdrop = () => {
519
+ if (modelBackdrop || hostBackdropPresent()) return;
520
+ modelBackdrop = document.createElement('div');
521
+ modelBackdrop.className = 'qzsy-model-sheet-backdrop';
522
+ modelBackdrop.setAttribute('aria-hidden', 'true');
523
+ modelBackdrop.addEventListener('pointerdown', (event) => event.stopPropagation(), true);
524
+ modelBackdrop.addEventListener('click', (event) => {
525
+ event.preventDefault();
526
+ event.stopPropagation();
527
+ closeModelSheetNow();
528
+ });
529
+ document.body.appendChild(modelBackdrop);
530
+ };
531
+
532
+ const detachModelDrag = () => {
533
+ if (!modelPortal || !modelDragHandlers) return;
534
+ modelPortal.removeEventListener('touchstart', modelDragHandlers.onStart);
535
+ modelPortal.removeEventListener('touchmove', modelDragHandlers.onMove);
536
+ modelPortal.removeEventListener('touchend', modelDragHandlers.onEnd);
537
+ modelPortal.removeEventListener('touchcancel', modelDragHandlers.onEnd);
538
+ modelDragHandlers = null;
539
+ };
540
+
541
+ const attachModelDrag = (portal) => {
542
+ if (!portal || modelDragHandlers) return;
543
+ const onStart = (event) => {
544
+ if (event.touches?.length !== 1) return;
545
+ const touch = event.touches[0];
546
+ const rect = portal.getBoundingClientRect();
547
+ if (touch.clientY - rect.top > 30) return;
548
+ if (event.target.closest?.('input, textarea, select, button, a, [contenteditable="true"]')) return;
549
+ modelDrag = { startY: touch.clientY, startT: Date.now(), dy: 0, active: false, height: rect.height };
550
+ };
551
+ const onMove = (event) => {
552
+ if (!modelDrag) return;
553
+ const touch = event.touches?.[0];
554
+ if (!touch) return;
555
+ const dy = touch.clientY - modelDrag.startY;
556
+ if (!modelDrag.active) {
557
+ if (dy < 6) return;
558
+ modelDrag.active = true;
559
+ document.body.classList.add('qzsy-model-sheet-dragging');
560
+ }
561
+ modelDrag.dy = Math.max(0, dy);
562
+ document.body.style.setProperty('--qzsy-model-sheet-y', `${modelDrag.dy}px`);
563
+ document.body.style.setProperty('--qzsy-model-sheet-progress', String(Math.max(0, 1 - modelDrag.dy / Math.max(1, modelDrag.height))));
564
+ if (event.cancelable) event.preventDefault();
565
+ };
566
+ const onEnd = () => {
567
+ if (!modelDrag) return;
568
+ const drag = modelDrag;
569
+ modelDrag = null;
570
+ if (!drag.active) return;
571
+ document.body.classList.remove('qzsy-model-sheet-dragging');
572
+ document.body.style.removeProperty('--qzsy-model-sheet-y');
573
+ document.body.style.removeProperty('--qzsy-model-sheet-progress');
574
+ const velocity = drag.dy / Math.max(1, Date.now() - drag.startT);
575
+ if (drag.dy > 110 || velocity > 0.5) closeModelSheetAnimated();
576
+ };
577
+ portal.addEventListener('touchstart', onStart, { passive: true });
578
+ portal.addEventListener('touchmove', onMove, { passive: false });
579
+ portal.addEventListener('touchend', onEnd, { passive: true });
580
+ portal.addEventListener('touchcancel', onEnd, { passive: true });
581
+ modelDragHandlers = { onStart, onMove, onEnd };
582
+ };
583
+
584
+ const clearModelSheet = () => {
585
+ if (modelPortal) {
586
+ detachModelDrag();
587
+ modelPortal.removeAttribute('data-qzsy-model-sheet');
588
+ modelPortal.classList.remove('qzsy-model-sheet', 'qzsy-model-sheet-closing');
589
+ }
590
+ modelPortal = null;
591
+ modelDrag = null;
592
+ document.body?.classList.remove('qzsy-model-sheet-open', 'qzsy-model-sheet-dragging');
593
+ document.body?.style.removeProperty('--qzsy-model-sheet-y');
594
+ document.body?.style.removeProperty('--qzsy-model-sheet-progress');
595
+ if (modelBackdrop) {
596
+ modelBackdrop.remove();
597
+ modelBackdrop = null;
598
+ }
599
+ };
600
+
601
+ const applyModelSheet = (portal) => {
602
+ if (modelPortal === portal) return;
603
+ clearModelSheet();
604
+ modelPortal = portal;
605
+ portal.setAttribute('data-qzsy-model-sheet', '');
606
+ portal.classList.add('qzsy-model-sheet');
607
+ attachModelDrag(portal);
608
+ ensureModelBackdrop();
609
+ window.requestAnimationFrame(() => {
610
+ if (!disposed && modelPortal === portal) document.body.classList.add('qzsy-model-sheet-open');
611
+ });
612
+ };
613
+
614
+ const syncModelSheet = () => {
615
+ if (disposed) return;
616
+ if (!isMobileViewport()) {
617
+ clearModelSheet();
618
+ return;
619
+ }
620
+ if (modelPortal && !visiblePortal(modelPortal)) clearModelSheet();
621
+ const found = findModelPortal();
622
+ if (!found) {
623
+ if (modelPortal?.isConnected) return; // 已应用,等待宿主关闭后移除
624
+ if (modelPortal) clearModelSheet();
625
+ return;
626
+ }
627
+ if (found !== modelPortal) applyModelSheet(found);
628
+ };
629
+
299
630
  const onClick = (event) => {
300
631
  if (Date.now() < suppressClickUntil) {
301
632
  event.preventDefault();
@@ -311,6 +642,14 @@ export function setupMobileDrawer() {
311
642
  else openDrawer();
312
643
  return;
313
644
  }
645
+
646
+ // 模型切换器:记录触发链,等宿主把二级菜单 portal 挂到 body 后再改造成 sheet
647
+ if (isModelTrigger(target)) {
648
+ modelChainUntil = Date.now() + 6000;
649
+ window.setTimeout(syncModelSheet, 30);
650
+ window.setTimeout(syncModelSheet, 180);
651
+ }
652
+
314
653
  if (!isOpen()) return;
315
654
 
316
655
  if (target.closest(`.${BACKDROP_CLASS}`)) {
@@ -353,6 +692,7 @@ export function setupMobileDrawer() {
353
692
  clearDrag();
354
693
  closeDrawer({ restoreFocus: false });
355
694
  }
695
+ syncModelSheet();
356
696
  };
357
697
 
358
698
  const onVisibilityChange = () => {
@@ -361,11 +701,12 @@ export function setupMobileDrawer() {
361
701
  clearDrag();
362
702
  closeDrawer({ restoreFocus: false });
363
703
  }
704
+ if (document.visibilityState === 'visible') syncModelSheet();
364
705
  };
365
706
 
366
707
  // ---- 手势:左缘右滑打开;抽屉内左滑关闭;拖动跟手 ----
367
708
  const onTouchStart = (event) => {
368
- if (disposed || !isMobileViewport() || event.touches?.length !== 1) return;
709
+ if (modelPortal || disposed || !isMobileViewport() || event.touches?.length !== 1) return;
369
710
  const touch = event.touches[0];
370
711
  const sidebar = document.querySelector('div[class*="_sidebarCol"]');
371
712
  if (!sidebar) return;
@@ -401,7 +742,7 @@ export function setupMobileDrawer() {
401
742
  };
402
743
 
403
744
  const onTouchMove = (event) => {
404
- if (!gesture || disposed) return;
745
+ if (modelPortal || !gesture || disposed) return;
405
746
  const touch = event.touches?.[0];
406
747
  if (!touch) return;
407
748
  const dx = touch.clientX - gesture.startX;
@@ -450,12 +791,12 @@ export function setupMobileDrawer() {
450
791
  };
451
792
 
452
793
  const onTouchEnd = (event) => {
453
- if (!gesture) return;
794
+ if (modelPortal || !gesture) return;
454
795
  finishGesture(event.changedTouches?.[0]);
455
796
  };
456
797
 
457
798
  const onTouchCancel = () => {
458
- if (!gesture) return;
799
+ if (modelPortal || !gesture) return;
459
800
  finishGesture(null);
460
801
  };
461
802
 
@@ -480,10 +821,12 @@ export function setupMobileDrawer() {
480
821
  if (!document.querySelector(`.${HEADER_CLASS}`) || !document.querySelector(`.${MENU_CLASS}`) || !document.querySelector(`.${BACKDROP_CLASS}`) || !document.getElementById(MOBILE_DRAWER_CSS_ID)) {
481
822
  ensureElements();
482
823
  }
824
+ syncModelSheet();
483
825
  });
484
826
  observer.observe(document.body, { childList: true });
485
827
  }
486
828
 
829
+ syncModelSheet();
487
830
  syncAria();
488
831
 
489
832
  return () => {
@@ -498,6 +841,7 @@ export function setupMobileDrawer() {
498
841
  window.removeEventListener('touchmove', onTouchMove);
499
842
  window.removeEventListener('touchend', onTouchEnd);
500
843
  window.removeEventListener('touchcancel', onTouchCancel);
844
+ clearModelSheet();
501
845
  clearDrag();
502
846
  document.body?.classList.remove(OPEN_CLASS);
503
847
  document.body?.classList.remove('qzsy-drawer-ready');
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@qzsy/dsh-bridge",
3
- "version": "3.3.1",
3
+ "version": "3.4.0",
4
4
  "description": "DSH 远程隧道云官方客户端插件:一条命令把本机 DeepSeek Harness 接入云端隧道(设备码绑定、短期令牌、本地门禁代理、HTTP/SSE/WebSocket 反向隧道)。",
5
- "releaseNotes": "【v3.3.1】移动端安全区与手势修复\\n• 全面屏:菜单按钮/内容/抽屉按 safe-area-inset 让位,刘海、状态栏、home indicator 不再遮挡\\n• 手势:左缘右滑打开、抽屉内左滑关闭;拖动跟手、遮罩渐变,松手按距离/速度吸附\\n• 纵向滚动不误触抽屉;滑动后的合成点击会被吞掉\\n• 首次加载不再播放侧栏滑出动画",
5
+ "releaseNotes": "【v3.4.0】移动端模型切换器改为底部 sheet\\n• 模型二级菜单全宽贴底:圆角、抓手、遮罩、滚动区、safe-area 底部留白\\n• 支持点遮罩关闭、顶部下拉关闭(跟手 + 松手吸附)\\n• 兼容有/无「搜索模型」输入框的宿主版本,不依赖宿主哈希\\n• 桌面端模型 popover 完全不变",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
8
8
  "exports": {