@qzsy/dsh-bridge 3.3.1 → 3.4.1

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,30 @@
1
1
  # 更新日志
2
2
 
3
+ ## [v3.4.1] - 2026-11-02
4
+
5
+ ### 🐛 修复
6
+
7
+ - **模型 sheet 识别收紧**:模型菜单被改造成 sheet 后立刻清空触发链,并优先用 `data-model-id` 等模型标记定位;关闭模型面板后紧接着打开账号等其它菜单,不会误套用底部 sheet。
8
+ - **无搜索框宿主兜底**:触发链窗口从 6 秒收紧到 2.5 秒,只对「模型触发后出现、且行数 > 2」的菜单生效。
9
+
10
+ ### 🧪 验证
11
+
12
+ - 实测:第一层「模型 / 推理等级」popover 不改造;二级模型菜单 sheet 化;关闭后再开账号菜单,`data-qzsy-model-sheet` 数量为 0,无残留遮罩。
13
+
14
+
15
+ ## [v3.4.0] - 2026-11-02
16
+
17
+ ### ✨ 新增
18
+
19
+ - **移动端模型切换器底部 sheet**:宿主的模型二级菜单从桌面 popover 改成全宽底部 sheet——圆角、抓手、遮罩、滚动区、`safe-area` 底部留白,切换模型更接近原生。
20
+ - **模型 sheet 手势**:点遮罩关闭;在顶部抓手上向下拖关闭(跟手 + 松手吸附);桌面端 popover 完全不变。
21
+ - **兼容有/无搜索框的宿主**:优先用「搜索模型」输入框定位;没有搜索框的移动版本按模型触发链 + 菜单行数兜底,不依赖宿主 CSS Modules 哈希。
22
+
23
+ ### 🧪 验证
24
+
25
+ - 真实 DSH + 无头 Chrome:模型二级菜单变成 390px 全宽、`z=1200`、18px 顶部圆角、遮罩 `z=1190`;底部安全区叠加后内边距 48px;点遮罩 / 顶部下拉均能关闭,7/7 通过。
26
+
27
+
3
28
  ## [v3.3.1] - 2026-11-02
4
29
 
5
30
  ### 🐛 修复
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,221 @@ 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
+ const candidates = [];
628
+ for (const el of document.body.children) {
629
+ if (!(el instanceof HTMLElement)) continue;
630
+ if (el === modelBackdrop || el.classList.contains(HEADER_CLASS) || el.classList.contains(BACKDROP_CLASS)) continue;
631
+ if (!visiblePortal(el)) continue;
632
+ const s = getComputedStyle(el);
633
+ if (s.position !== "fixed" && s.position !== "absolute") continue;
634
+ const menu = el.querySelector('[role="menu"], [role="listbox"]');
635
+ if (!menu) continue;
636
+ const text = menu.innerText || "";
637
+ const rows = menu.querySelectorAll('[role="menuitem"], [role="option"], button, a, [tabindex]').length;
638
+ const hasSearch = Boolean(el.querySelector('input[placeholder*="\u641C\u7D22\u6A21\u578B"], input[placeholder*="Search model"]'));
639
+ const hasModelMarker = Boolean(el.querySelector("[data-model-id], [data-provider-id], [data-model], [data-model-name]"));
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
+ const chained = Date.now() < modelChainUntil && rows > 2;
645
+ if (hasSearch || hasModelMarker || chained) candidates.push({ el, rows, hasSearch, hasModelMarker });
646
+ }
647
+ candidates.sort((a, b) => Number(b.hasSearch) - Number(a.hasSearch) || Number(b.hasModelMarker) - Number(a.hasModelMarker) || b.rows - a.rows);
648
+ return candidates[0]?.el || null;
649
+ };
650
+ const hostBackdropPresent = () => {
651
+ for (const el of document.body.children) {
652
+ if (!(el instanceof HTMLElement)) continue;
653
+ if (el === modelBackdrop || el.classList.contains(HEADER_CLASS) || el.classList.contains(BACKDROP_CLASS)) continue;
654
+ const s = getComputedStyle(el);
655
+ if (s.position !== "fixed") continue;
656
+ const r = el.getBoundingClientRect();
657
+ if (r.width < window.innerWidth - 4 || r.height < window.innerHeight - 4) continue;
658
+ if (Number(s.opacity) < 0.1) continue;
659
+ const bg = s.backgroundColor || "";
660
+ if (!/^rgba?\(/.test(bg) || /rgba\(0,\s*0,\s*0,\s*0\)/.test(bg)) continue;
661
+ if (el.querySelector('[role="menu"]')) continue;
662
+ return true;
663
+ }
664
+ return false;
665
+ };
666
+ const requestHostModelClose = (portal) => {
667
+ if (disposed || !portal || !portal.isConnected) return;
668
+ const PointerCtor = typeof window.PointerEvent === "function" ? window.PointerEvent : window.MouseEvent;
669
+ try {
670
+ document.body.dispatchEvent(new PointerCtor("pointerdown", { bubbles: true, cancelable: true }));
671
+ } catch {
672
+ }
673
+ try {
674
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
675
+ } catch {
676
+ }
677
+ window.setTimeout(() => {
678
+ if (disposed || !portal.isConnected) return;
679
+ 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"]');
680
+ try {
681
+ trigger?.click();
682
+ } catch {
683
+ }
684
+ window.setTimeout(() => {
685
+ if (!disposed && portal.isConnected && modelPortal === portal) clearModelSheet();
686
+ }, 450);
687
+ }, 120);
688
+ };
689
+ const closeModelSheetNow = () => {
690
+ const portal = modelPortal;
691
+ if (!portal) return;
692
+ document.body.classList.remove("qzsy-model-sheet-open");
693
+ document.body.style.removeProperty("--qzsy-model-sheet-y");
694
+ document.body.style.removeProperty("--qzsy-model-sheet-progress");
695
+ requestHostModelClose(portal);
696
+ };
697
+ const closeModelSheetAnimated = () => {
698
+ const portal = modelPortal;
699
+ if (!portal) return;
700
+ portal.classList.add("qzsy-model-sheet-closing");
701
+ document.body.classList.remove("qzsy-model-sheet-open");
702
+ window.setTimeout(() => requestHostModelClose(portal), 180);
703
+ };
704
+ const ensureModelBackdrop = () => {
705
+ if (modelBackdrop || hostBackdropPresent()) return;
706
+ modelBackdrop = document.createElement("div");
707
+ modelBackdrop.className = "qzsy-model-sheet-backdrop";
708
+ modelBackdrop.setAttribute("aria-hidden", "true");
709
+ modelBackdrop.addEventListener("pointerdown", (event) => event.stopPropagation(), true);
710
+ modelBackdrop.addEventListener("click", (event) => {
711
+ event.preventDefault();
712
+ event.stopPropagation();
713
+ closeModelSheetNow();
714
+ });
715
+ document.body.appendChild(modelBackdrop);
716
+ };
717
+ const detachModelDrag = () => {
718
+ if (!modelPortal || !modelDragHandlers) return;
719
+ modelPortal.removeEventListener("touchstart", modelDragHandlers.onStart);
720
+ modelPortal.removeEventListener("touchmove", modelDragHandlers.onMove);
721
+ modelPortal.removeEventListener("touchend", modelDragHandlers.onEnd);
722
+ modelPortal.removeEventListener("touchcancel", modelDragHandlers.onEnd);
723
+ modelDragHandlers = null;
724
+ };
725
+ const attachModelDrag = (portal) => {
726
+ if (!portal || modelDragHandlers) return;
727
+ const onStart = (event) => {
728
+ if (event.touches?.length !== 1) return;
729
+ const touch = event.touches[0];
730
+ const rect = portal.getBoundingClientRect();
731
+ if (touch.clientY - rect.top > 30) return;
732
+ if (event.target.closest?.('input, textarea, select, button, a, [contenteditable="true"]')) return;
733
+ modelDrag = { startY: touch.clientY, startT: Date.now(), dy: 0, active: false, height: rect.height };
734
+ };
735
+ const onMove = (event) => {
736
+ if (!modelDrag) return;
737
+ const touch = event.touches?.[0];
738
+ if (!touch) return;
739
+ const dy = touch.clientY - modelDrag.startY;
740
+ if (!modelDrag.active) {
741
+ if (dy < 6) return;
742
+ modelDrag.active = true;
743
+ document.body.classList.add("qzsy-model-sheet-dragging");
744
+ }
745
+ modelDrag.dy = Math.max(0, dy);
746
+ document.body.style.setProperty("--qzsy-model-sheet-y", `${modelDrag.dy}px`);
747
+ document.body.style.setProperty("--qzsy-model-sheet-progress", String(Math.max(0, 1 - modelDrag.dy / Math.max(1, modelDrag.height))));
748
+ if (event.cancelable) event.preventDefault();
749
+ };
750
+ const onEnd = () => {
751
+ if (!modelDrag) return;
752
+ const drag = modelDrag;
753
+ modelDrag = null;
754
+ if (!drag.active) return;
755
+ document.body.classList.remove("qzsy-model-sheet-dragging");
756
+ document.body.style.removeProperty("--qzsy-model-sheet-y");
757
+ document.body.style.removeProperty("--qzsy-model-sheet-progress");
758
+ const velocity = drag.dy / Math.max(1, Date.now() - drag.startT);
759
+ if (drag.dy > 110 || velocity > 0.5) closeModelSheetAnimated();
760
+ };
761
+ portal.addEventListener("touchstart", onStart, { passive: true });
762
+ portal.addEventListener("touchmove", onMove, { passive: false });
763
+ portal.addEventListener("touchend", onEnd, { passive: true });
764
+ portal.addEventListener("touchcancel", onEnd, { passive: true });
765
+ modelDragHandlers = { onStart, onMove, onEnd };
766
+ };
767
+ const clearModelSheet = () => {
768
+ if (modelPortal) {
769
+ detachModelDrag();
770
+ modelPortal.removeAttribute("data-qzsy-model-sheet");
771
+ modelPortal.classList.remove("qzsy-model-sheet", "qzsy-model-sheet-closing");
772
+ }
773
+ modelPortal = null;
774
+ modelDrag = null;
775
+ document.body?.classList.remove("qzsy-model-sheet-open", "qzsy-model-sheet-dragging");
776
+ document.body?.style.removeProperty("--qzsy-model-sheet-y");
777
+ document.body?.style.removeProperty("--qzsy-model-sheet-progress");
778
+ if (modelBackdrop) {
779
+ modelBackdrop.remove();
780
+ modelBackdrop = null;
781
+ }
782
+ };
783
+ const applyModelSheet = (portal) => {
784
+ if (modelPortal === portal) return;
785
+ clearModelSheet();
786
+ modelPortal = portal;
787
+ modelChainUntil = 0;
788
+ portal.setAttribute("data-qzsy-model-sheet", "");
789
+ portal.classList.add("qzsy-model-sheet");
790
+ attachModelDrag(portal);
791
+ ensureModelBackdrop();
792
+ window.requestAnimationFrame(() => {
793
+ if (!disposed && modelPortal === portal) document.body.classList.add("qzsy-model-sheet-open");
794
+ });
795
+ };
796
+ const syncModelSheet = () => {
797
+ if (disposed) return;
798
+ if (!isMobileViewport()) {
799
+ clearModelSheet();
800
+ return;
801
+ }
802
+ if (modelPortal && !visiblePortal(modelPortal)) clearModelSheet();
803
+ const found = findModelPortal();
804
+ if (!found) {
805
+ if (modelPortal?.isConnected) return;
806
+ if (modelPortal) clearModelSheet();
807
+ return;
808
+ }
809
+ if (found !== modelPortal) applyModelSheet(found);
810
+ };
487
811
  const onClick = (event) => {
488
812
  if (Date.now() < suppressClickUntil) {
489
813
  event.preventDefault();
@@ -498,6 +822,11 @@ function setupMobileDrawer() {
498
822
  else openDrawer();
499
823
  return;
500
824
  }
825
+ if (isModelTrigger(target)) {
826
+ modelChainUntil = Date.now() + 2500;
827
+ window.setTimeout(syncModelSheet, 30);
828
+ window.setTimeout(syncModelSheet, 180);
829
+ }
501
830
  if (!isOpen()) return;
502
831
  if (target.closest(`.${BACKDROP_CLASS}`)) {
503
832
  closeDrawer();
@@ -531,6 +860,7 @@ function setupMobileDrawer() {
531
860
  clearDrag();
532
861
  closeDrawer({ restoreFocus: false });
533
862
  }
863
+ syncModelSheet();
534
864
  };
535
865
  const onVisibilityChange = () => {
536
866
  if (document.visibilityState === "hidden" && isOpen()) {
@@ -538,9 +868,10 @@ function setupMobileDrawer() {
538
868
  clearDrag();
539
869
  closeDrawer({ restoreFocus: false });
540
870
  }
871
+ if (document.visibilityState === "visible") syncModelSheet();
541
872
  };
542
873
  const onTouchStart = (event) => {
543
- if (disposed || !isMobileViewport() || event.touches?.length !== 1) return;
874
+ if (modelPortal || disposed || !isMobileViewport() || event.touches?.length !== 1) return;
544
875
  const touch = event.touches[0];
545
876
  const sidebar = document.querySelector('div[class*="_sidebarCol"]');
546
877
  if (!sidebar) return;
@@ -572,7 +903,7 @@ function setupMobileDrawer() {
572
903
  };
573
904
  };
574
905
  const onTouchMove = (event) => {
575
- if (!gesture || disposed) return;
906
+ if (modelPortal || !gesture || disposed) return;
576
907
  const touch = event.touches?.[0];
577
908
  if (!touch) return;
578
909
  const dx = touch.clientX - gesture.startX;
@@ -620,11 +951,11 @@ function setupMobileDrawer() {
620
951
  window.requestAnimationFrame(clearDrag);
621
952
  };
622
953
  const onTouchEnd = (event) => {
623
- if (!gesture) return;
954
+ if (modelPortal || !gesture) return;
624
955
  finishGesture(event.changedTouches?.[0]);
625
956
  };
626
957
  const onTouchCancel = () => {
627
- if (!gesture) return;
958
+ if (modelPortal || !gesture) return;
628
959
  finishGesture(null);
629
960
  };
630
961
  injectStyles();
@@ -645,9 +976,11 @@ function setupMobileDrawer() {
645
976
  if (!document.querySelector(`.${HEADER_CLASS}`) || !document.querySelector(`.${MENU_CLASS}`) || !document.querySelector(`.${BACKDROP_CLASS}`) || !document.getElementById(MOBILE_DRAWER_CSS_ID)) {
646
977
  ensureElements();
647
978
  }
979
+ syncModelSheet();
648
980
  });
649
981
  observer.observe(document.body, { childList: true });
650
982
  }
983
+ syncModelSheet();
651
984
  syncAria();
652
985
  return () => {
653
986
  disposed = true;
@@ -664,6 +997,7 @@ function setupMobileDrawer() {
664
997
  window.removeEventListener("touchmove", onTouchMove);
665
998
  window.removeEventListener("touchend", onTouchEnd);
666
999
  window.removeEventListener("touchcancel", onTouchCancel);
1000
+ clearModelSheet();
667
1001
  clearDrag();
668
1002
  document.body?.classList.remove(OPEN_CLASS);
669
1003
  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,232 @@ 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
+ const candidates = [];
445
+ for (const el of document.body.children) {
446
+ if (!(el instanceof HTMLElement)) continue;
447
+ if (el === modelBackdrop || el.classList.contains(HEADER_CLASS) || el.classList.contains(BACKDROP_CLASS)) continue;
448
+ if (!visiblePortal(el)) continue;
449
+ const s = getComputedStyle(el);
450
+ if (s.position !== 'fixed' && s.position !== 'absolute') continue;
451
+ const menu = el.querySelector('[role="menu"], [role="listbox"]');
452
+ if (!menu) continue;
453
+ const text = menu.innerText || '';
454
+ const rows = menu.querySelectorAll('[role="menuitem"], [role="option"], button, a, [tabindex]').length;
455
+ const hasSearch = Boolean(el.querySelector('input[placeholder*="搜索模型"], input[placeholder*="Search model"]'));
456
+ const hasModelMarker = Boolean(el.querySelector('[data-model-id], [data-provider-id], [data-model], [data-model-name]'));
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
+ const chained = Date.now() < modelChainUntil && rows > 2;
464
+ if (hasSearch || hasModelMarker || chained) candidates.push({ el, rows, hasSearch, hasModelMarker });
465
+ }
466
+ candidates.sort((a, b) => Number(b.hasSearch) - Number(a.hasSearch)
467
+ || Number(b.hasModelMarker) - Number(a.hasModelMarker)
468
+ || b.rows - a.rows);
469
+ return candidates[0]?.el || null;
470
+ };
471
+
472
+ const hostBackdropPresent = () => {
473
+ for (const el of document.body.children) {
474
+ if (!(el instanceof HTMLElement)) continue;
475
+ if (el === modelBackdrop || el.classList.contains(HEADER_CLASS) || el.classList.contains(BACKDROP_CLASS)) continue;
476
+ const s = getComputedStyle(el);
477
+ if (s.position !== 'fixed') continue;
478
+ const r = el.getBoundingClientRect();
479
+ if (r.width < window.innerWidth - 4 || r.height < window.innerHeight - 4) continue;
480
+ if (Number(s.opacity) < 0.1) continue;
481
+ const bg = s.backgroundColor || '';
482
+ if (!/^rgba?\(/.test(bg) || /rgba\(0,\s*0,\s*0,\s*0\)/.test(bg)) continue;
483
+ if (el.querySelector('[role="menu"]')) continue;
484
+ return true;
485
+ }
486
+ return false;
487
+ };
488
+
489
+ const requestHostModelClose = (portal) => {
490
+ if (disposed || !portal || !portal.isConnected) return;
491
+ const PointerCtor = typeof window.PointerEvent === 'function' ? window.PointerEvent : window.MouseEvent;
492
+ try { document.body.dispatchEvent(new PointerCtor('pointerdown', { bubbles: true, cancelable: true })); } catch { /* 忽略 */ }
493
+ try { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); } catch { /* 忽略 */ }
494
+ window.setTimeout(() => {
495
+ if (disposed || !portal.isConnected) return;
496
+ const trigger = document.querySelector('button[aria-haspopup="menu"][aria-expanded="true"][aria-label*="选择模型"], button[aria-haspopup="menu"][aria-expanded="true"][aria-label*="Select model"]');
497
+ try { trigger?.click(); } catch { /* 忽略 */ }
498
+ window.setTimeout(() => {
499
+ if (!disposed && portal.isConnected && modelPortal === portal) clearModelSheet();
500
+ }, 450);
501
+ }, 120);
502
+ };
503
+
504
+ const closeModelSheetNow = () => {
505
+ const portal = modelPortal;
506
+ if (!portal) return;
507
+ document.body.classList.remove('qzsy-model-sheet-open');
508
+ document.body.style.removeProperty('--qzsy-model-sheet-y');
509
+ document.body.style.removeProperty('--qzsy-model-sheet-progress');
510
+ requestHostModelClose(portal);
511
+ };
512
+
513
+ const closeModelSheetAnimated = () => {
514
+ const portal = modelPortal;
515
+ if (!portal) return;
516
+ portal.classList.add('qzsy-model-sheet-closing');
517
+ document.body.classList.remove('qzsy-model-sheet-open');
518
+ window.setTimeout(() => requestHostModelClose(portal), 180);
519
+ };
520
+
521
+ const ensureModelBackdrop = () => {
522
+ if (modelBackdrop || hostBackdropPresent()) return;
523
+ modelBackdrop = document.createElement('div');
524
+ modelBackdrop.className = 'qzsy-model-sheet-backdrop';
525
+ modelBackdrop.setAttribute('aria-hidden', 'true');
526
+ modelBackdrop.addEventListener('pointerdown', (event) => event.stopPropagation(), true);
527
+ modelBackdrop.addEventListener('click', (event) => {
528
+ event.preventDefault();
529
+ event.stopPropagation();
530
+ closeModelSheetNow();
531
+ });
532
+ document.body.appendChild(modelBackdrop);
533
+ };
534
+
535
+ const detachModelDrag = () => {
536
+ if (!modelPortal || !modelDragHandlers) return;
537
+ modelPortal.removeEventListener('touchstart', modelDragHandlers.onStart);
538
+ modelPortal.removeEventListener('touchmove', modelDragHandlers.onMove);
539
+ modelPortal.removeEventListener('touchend', modelDragHandlers.onEnd);
540
+ modelPortal.removeEventListener('touchcancel', modelDragHandlers.onEnd);
541
+ modelDragHandlers = null;
542
+ };
543
+
544
+ const attachModelDrag = (portal) => {
545
+ if (!portal || modelDragHandlers) return;
546
+ const onStart = (event) => {
547
+ if (event.touches?.length !== 1) return;
548
+ const touch = event.touches[0];
549
+ const rect = portal.getBoundingClientRect();
550
+ if (touch.clientY - rect.top > 30) return;
551
+ if (event.target.closest?.('input, textarea, select, button, a, [contenteditable="true"]')) return;
552
+ modelDrag = { startY: touch.clientY, startT: Date.now(), dy: 0, active: false, height: rect.height };
553
+ };
554
+ const onMove = (event) => {
555
+ if (!modelDrag) return;
556
+ const touch = event.touches?.[0];
557
+ if (!touch) return;
558
+ const dy = touch.clientY - modelDrag.startY;
559
+ if (!modelDrag.active) {
560
+ if (dy < 6) return;
561
+ modelDrag.active = true;
562
+ document.body.classList.add('qzsy-model-sheet-dragging');
563
+ }
564
+ modelDrag.dy = Math.max(0, dy);
565
+ document.body.style.setProperty('--qzsy-model-sheet-y', `${modelDrag.dy}px`);
566
+ document.body.style.setProperty('--qzsy-model-sheet-progress', String(Math.max(0, 1 - modelDrag.dy / Math.max(1, modelDrag.height))));
567
+ if (event.cancelable) event.preventDefault();
568
+ };
569
+ const onEnd = () => {
570
+ if (!modelDrag) return;
571
+ const drag = modelDrag;
572
+ modelDrag = null;
573
+ if (!drag.active) return;
574
+ document.body.classList.remove('qzsy-model-sheet-dragging');
575
+ document.body.style.removeProperty('--qzsy-model-sheet-y');
576
+ document.body.style.removeProperty('--qzsy-model-sheet-progress');
577
+ const velocity = drag.dy / Math.max(1, Date.now() - drag.startT);
578
+ if (drag.dy > 110 || velocity > 0.5) closeModelSheetAnimated();
579
+ };
580
+ portal.addEventListener('touchstart', onStart, { passive: true });
581
+ portal.addEventListener('touchmove', onMove, { passive: false });
582
+ portal.addEventListener('touchend', onEnd, { passive: true });
583
+ portal.addEventListener('touchcancel', onEnd, { passive: true });
584
+ modelDragHandlers = { onStart, onMove, onEnd };
585
+ };
586
+
587
+ const clearModelSheet = () => {
588
+ if (modelPortal) {
589
+ detachModelDrag();
590
+ modelPortal.removeAttribute('data-qzsy-model-sheet');
591
+ modelPortal.classList.remove('qzsy-model-sheet', 'qzsy-model-sheet-closing');
592
+ }
593
+ modelPortal = null;
594
+ modelDrag = null;
595
+ document.body?.classList.remove('qzsy-model-sheet-open', 'qzsy-model-sheet-dragging');
596
+ document.body?.style.removeProperty('--qzsy-model-sheet-y');
597
+ document.body?.style.removeProperty('--qzsy-model-sheet-progress');
598
+ if (modelBackdrop) {
599
+ modelBackdrop.remove();
600
+ modelBackdrop = null;
601
+ }
602
+ };
603
+
604
+ const applyModelSheet = (portal) => {
605
+ if (modelPortal === portal) return;
606
+ clearModelSheet();
607
+ modelPortal = portal;
608
+ modelChainUntil = 0;
609
+ portal.setAttribute('data-qzsy-model-sheet', '');
610
+ portal.classList.add('qzsy-model-sheet');
611
+ attachModelDrag(portal);
612
+ ensureModelBackdrop();
613
+ window.requestAnimationFrame(() => {
614
+ if (!disposed && modelPortal === portal) document.body.classList.add('qzsy-model-sheet-open');
615
+ });
616
+ };
617
+
618
+ const syncModelSheet = () => {
619
+ if (disposed) return;
620
+ if (!isMobileViewport()) {
621
+ clearModelSheet();
622
+ return;
623
+ }
624
+ if (modelPortal && !visiblePortal(modelPortal)) clearModelSheet();
625
+ const found = findModelPortal();
626
+ if (!found) {
627
+ if (modelPortal?.isConnected) return; // 已应用,等待宿主关闭后移除
628
+ if (modelPortal) clearModelSheet();
629
+ return;
630
+ }
631
+ if (found !== modelPortal) applyModelSheet(found);
632
+ };
633
+
299
634
  const onClick = (event) => {
300
635
  if (Date.now() < suppressClickUntil) {
301
636
  event.preventDefault();
@@ -311,6 +646,14 @@ export function setupMobileDrawer() {
311
646
  else openDrawer();
312
647
  return;
313
648
  }
649
+
650
+ // 模型切换器:记录触发链,等宿主把二级菜单 portal 挂到 body 后再改造成 sheet
651
+ if (isModelTrigger(target)) {
652
+ modelChainUntil = Date.now() + 2500;
653
+ window.setTimeout(syncModelSheet, 30);
654
+ window.setTimeout(syncModelSheet, 180);
655
+ }
656
+
314
657
  if (!isOpen()) return;
315
658
 
316
659
  if (target.closest(`.${BACKDROP_CLASS}`)) {
@@ -353,6 +696,7 @@ export function setupMobileDrawer() {
353
696
  clearDrag();
354
697
  closeDrawer({ restoreFocus: false });
355
698
  }
699
+ syncModelSheet();
356
700
  };
357
701
 
358
702
  const onVisibilityChange = () => {
@@ -361,11 +705,12 @@ export function setupMobileDrawer() {
361
705
  clearDrag();
362
706
  closeDrawer({ restoreFocus: false });
363
707
  }
708
+ if (document.visibilityState === 'visible') syncModelSheet();
364
709
  };
365
710
 
366
711
  // ---- 手势:左缘右滑打开;抽屉内左滑关闭;拖动跟手 ----
367
712
  const onTouchStart = (event) => {
368
- if (disposed || !isMobileViewport() || event.touches?.length !== 1) return;
713
+ if (modelPortal || disposed || !isMobileViewport() || event.touches?.length !== 1) return;
369
714
  const touch = event.touches[0];
370
715
  const sidebar = document.querySelector('div[class*="_sidebarCol"]');
371
716
  if (!sidebar) return;
@@ -401,7 +746,7 @@ export function setupMobileDrawer() {
401
746
  };
402
747
 
403
748
  const onTouchMove = (event) => {
404
- if (!gesture || disposed) return;
749
+ if (modelPortal || !gesture || disposed) return;
405
750
  const touch = event.touches?.[0];
406
751
  if (!touch) return;
407
752
  const dx = touch.clientX - gesture.startX;
@@ -450,12 +795,12 @@ export function setupMobileDrawer() {
450
795
  };
451
796
 
452
797
  const onTouchEnd = (event) => {
453
- if (!gesture) return;
798
+ if (modelPortal || !gesture) return;
454
799
  finishGesture(event.changedTouches?.[0]);
455
800
  };
456
801
 
457
802
  const onTouchCancel = () => {
458
- if (!gesture) return;
803
+ if (modelPortal || !gesture) return;
459
804
  finishGesture(null);
460
805
  };
461
806
 
@@ -480,10 +825,12 @@ export function setupMobileDrawer() {
480
825
  if (!document.querySelector(`.${HEADER_CLASS}`) || !document.querySelector(`.${MENU_CLASS}`) || !document.querySelector(`.${BACKDROP_CLASS}`) || !document.getElementById(MOBILE_DRAWER_CSS_ID)) {
481
826
  ensureElements();
482
827
  }
828
+ syncModelSheet();
483
829
  });
484
830
  observer.observe(document.body, { childList: true });
485
831
  }
486
832
 
833
+ syncModelSheet();
487
834
  syncAria();
488
835
 
489
836
  return () => {
@@ -498,6 +845,7 @@ export function setupMobileDrawer() {
498
845
  window.removeEventListener('touchmove', onTouchMove);
499
846
  window.removeEventListener('touchend', onTouchEnd);
500
847
  window.removeEventListener('touchcancel', onTouchCancel);
848
+ clearModelSheet();
501
849
  clearDrag();
502
850
  document.body?.classList.remove(OPEN_CLASS);
503
851
  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.1",
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.1】模型 sheet 识别收紧\\n• 模型菜单改造后立即清空触发链,关闭后再开账号菜单不会误套底部 sheet\\n• 优先按 data-model-id 等模型标记定位;无搜索框宿主的兜底窗口收紧到 2.5 秒\\n• 第一层「模型 / 推理等级」popover 保持不变",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
8
8
  "exports": {