@gehennawu/dsh-service 0.28.0 → 0.30.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/README.en.md CHANGED
@@ -10,7 +10,7 @@ A service-control and operations plugin for self-hosted DSH Web. Provides safe r
10
10
 
11
11
  The Settings panel "Service Control" page has nine top-level tabs: **Overview, Notifications, Health, Model stats, Quota lookup, Backups, Skills, Subagents, Restart**; the Restart, Quota lookup, Skills, and Subagents tabs can each enable a quick entry at the bottom of the settings left navigation (off by default).
12
12
 
13
- The plugin also appears under **Plugins → Plugin configuration**, with eight host-level switches enabled by default: **Health diagnostics, Model statistics, Quota lookup, Backup maintenance, Task notifications, Skill manager, Subagent model, and the `/healthz` liveness endpoint**. Disabling one hides its UI, stops the associated polling/subscriptions, and makes the Host reject that capability; Overview and Restart remain available. All eight switches are live settings: disabling or re-enabling them requires neither a page reload nor a DSH Web restart. Statistics refreshes, quota requests, or backup operations already in flight are allowed to finish; quota re-enablement preserves existing cache, TTL, and backoff state, so its UI and calls return immediately but a new upstream request is not guaranteed at once.
13
+ The plugin also appears under **Plugins → Plugin configuration**, with nine host-level switches enabled by default: **Health diagnostics, Model statistics, Quota lookup, Backup maintenance, Task notifications, Skill manager, Subagent model, Mobile adaptation, and the `/healthz` liveness endpoint**. Disabling one hides its UI, stops the associated polling/subscriptions, and makes the Host reject that capability; Overview and Restart remain available. All nine switches are live settings: disabling or re-enabling them requires neither a page reload nor a DSH Web restart. Statistics refreshes, quota requests, or backup operations already in flight are allowed to finish; quota re-enablement preserves existing cache, TTL, and backoff state, so its UI and calls return immediately but a new upstream request is not guaranteed at once.
14
14
 
15
15
  ### Version and updates
16
16
 
@@ -97,6 +97,16 @@ The plugin also appears under **Plugins → Plugin configuration**, with eight h
97
97
  - Bell icon in the conversation input bar toggles the master switch quickly
98
98
  - All toggles persist across page reloads
99
99
 
100
+ ### Mobile adaptation
101
+
102
+ - On by default; active only on phones or narrow windows (viewport <1024px, matching the official shell's sidebar collapse breakpoint). Desktop widths are completely unaffected
103
+ - Sidebar becomes a drawer: the official sidebar slides into a left overlay drawer — a translucent floating button on the left edge toggles it, and tapping the backdrop or any session row closes it automatically. The preview/file-tree details column becomes a right overlay instead of squeezing the conversation area. Open/close goes through DSH's official `layout` service; no host DOM reparenting
104
+ - Modals become bottom sheets: settings dialogs turn into bottom sheets (rounded top corners, internal scrolling), and the settings left navigation becomes a horizontally scrollable top strip
105
+ - Touch details: adds `viewport-fit=cover` automatically and respects notch safe areas; double-tap no longer zooms the page; text inputs stay ≥16px so iOS no longer auto-zooms on focus
106
+ - Transparent large-JSON compression: the host compresses JSON responses ≥4KB with gzip/brotli chosen from the browser's `Accept-Encoding` (long session histories can reach tens of MB — compression noticeably speeds up first paint). Small payloads and other content types pass through byte-identical; SSE streams are untouched
107
+ - The "Mobile adaptation" switch in "Plugins → Plugin configuration" turns everything off: the host compression patch is restored and all UI enhancements unmount, returning to the native layout immediately without a restart
108
+ - Debug mode: append `?dshsvc-mobile-debug=1` to the URL to show a floating diagnostics chip (viewport size / breakpoint state / drawer & details state / JS error count). For debugging only
109
+
100
110
  ### External liveness probe
101
111
 
102
112
  - `GET` / `HEAD /healthz` returns empty HTTP 200; other methods return 405
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  设置页「服务控制」面板包含九个顶部标签:**概览、通知、健康诊断、模型统计、额度查询、备份维护、技能、子代理、重启**;重启、额度查询、技能与子代理标签还可在设置页左侧标签列底部开启快捷入口(默认关闭)。
12
12
 
13
- 插件同时出现在「插件 → 插件配置」,提供八个默认开启的宿主级开关:**健康诊断、模型统计、额度查询、备份维护、任务通知、技能管理、子代理模型、`/healthz` 探活端点**。关闭后不仅隐藏对应界面,也会停止相关轮询/订阅并由宿主拒绝对应能力;概览与重启固定保留。设置写入 DSH settings,八项均为热生效:关闭或重新开启都无需刷新页面或重启 DSH Web。已在途的统计刷新、额度请求或备份操作允许完成;额度重新开启时保留既有缓存、TTL 与退避状态,因此可立即恢复界面和调用,但不保证立刻重新请求上游。
13
+ 插件同时出现在「插件 → 插件配置」,提供九个默认开启的宿主级开关:**健康诊断、模型统计、额度查询、备份维护、任务通知、技能管理、子代理模型、移动端适配、`/healthz` 探活端点**。关闭后不仅隐藏对应界面,也会停止相关轮询/订阅并由宿主拒绝对应能力;概览与重启固定保留。设置写入 DSH settings,九项均为热生效:关闭或重新开启都无需刷新页面或重启 DSH Web。已在途的统计刷新、额度请求或备份操作允许完成;额度重新开启时保留既有缓存、TTL 与退避状态,因此可立即恢复界面和调用,但不保证立刻重新请求上游。
14
14
 
15
15
  ### 版本与更新
16
16
 
@@ -97,6 +97,16 @@
97
97
  - 对话栏内铃铛图标快速切换总开关
98
98
  - 各开关在页面刷新后保持
99
99
 
100
+ ### 移动端适配
101
+
102
+ - 默认开启,仅在手机竖屏或窄窗口(视口 <1024px,与官方外壳的侧栏折叠断点一致)下生效,桌面宽度完全无感
103
+ - 侧栏变抽屉:官方侧栏整体收进左侧 overlay 抽屉——左缘半透明悬浮按钮开合、点背板或点选任意会话后自动收起;预览/文件树详情列变右侧浮层,不再挤压会话区。开合走 DSH 官方 `layout` 服务,不重挂宿主 DOM
104
+ - 模态弹窗底部化:设置等对话框变为底部 sheet(上圆角、内部滚动),设置页左列导航变为顶部横向滑动条
105
+ - 触屏细节:自动补 `viewport-fit=cover` 并避让刘海安全区;按钮双击不再触发页面缩放;文本输入框保持 ≥16px,iOS 聚焦时不再自动放大
106
+ - 大 JSON 响应透明压缩:宿主对 ≥4KB 的 JSON 响应按浏览器 `Accept-Encoding` 自动 gzip/brotli 异步压缩(长会话历史可达十几 MB,压缩后首屏明显提速),小体积与其他类型字节级透传,SSE 流不受影响
107
+ - 「插件 → 插件配置」的「移动端适配」开关可整体关闭:关闭后宿主压缩补丁还原、界面增强全部卸载,立即回到原生布局,热生效无需重启
108
+ - 调试模式:URL 追加 `?dshsvc-mobile-debug=1` 显示浮动诊断条(视口尺寸 / 断点状态 / 抽屉与详情列状态 / JS 错误计数),仅调试用途
109
+
100
110
  ### 外部探活
101
111
 
102
112
  - `GET` / `HEAD /healthz` 返回空的 HTTP 200,其他方法返回 405
package/client.js CHANGED
@@ -26,6 +26,15 @@ window.__ModuleLoader__.load({
26
26
  'tabs.skills': '技能',
27
27
  'tabs.subagent': '子代理',
28
28
  'features.subagentRoute': '子代理模型',
29
+ 'features.mobileAdaptation': '移动端适配',
30
+ 'mobile.fab.label': '打开侧栏菜单',
31
+ 'mobile.debug.title': '移动端诊断',
32
+ 'mobile.debug.viewport': '视口',
33
+ 'mobile.debug.drawer': '抽屉',
34
+ 'mobile.debug.details': '预览列',
35
+ 'mobile.debug.errors': 'JS 错误',
36
+ 'mobile.debug.stateOn': '开',
37
+ 'mobile.debug.stateOff': '关',
29
38
  'subagent.title': '子代理模型',
30
39
  'subagent.hint': '控制「未显式指定模型」的子代理委派使用哪个模型;显式指定了模型的派生(预设钉死、其他插件注入、调用参数携带)不受影响。',
31
40
  'subagent.mode.label': '模式',
@@ -517,6 +526,15 @@ window.__ModuleLoader__.load({
517
526
  'tabs.skills': 'Skills',
518
527
  'tabs.subagent': 'Subagents',
519
528
  'features.subagentRoute': 'Subagent model',
529
+ 'features.mobileAdaptation': 'Mobile adaptation',
530
+ 'mobile.fab.label': 'Open sidebar menu',
531
+ 'mobile.debug.title': 'Mobile diagnostics',
532
+ 'mobile.debug.viewport': 'Viewport',
533
+ 'mobile.debug.drawer': 'Drawer',
534
+ 'mobile.debug.details': 'Details',
535
+ 'mobile.debug.errors': 'JS errors',
536
+ 'mobile.debug.stateOn': 'on',
537
+ 'mobile.debug.stateOff': 'off',
520
538
  'subagent.title': 'Subagent model',
521
539
  'subagent.hint': 'Controls which model a subagent delegation uses when no model was explicitly specified; delegations with an explicit route (pinned preset, another plugin, call arguments) are unaffected.',
522
540
  'subagent.mode.label': 'Mode',
@@ -1091,7 +1109,7 @@ window.__ModuleLoader__.load({
1091
1109
  if (number >= 1e3) return `${Math.round(number / 1e2) / 10}K`
1092
1110
  return number.toLocaleString()
1093
1111
  }
1094
- const DEFAULT_FEATURES = { healthDiagnostics: true, modelUsage: true, quotaLookup: true, backupMaintenance: true, taskNotifications: true, healthz: true, skillManager: true, subagentRoute: true }
1112
+ const DEFAULT_FEATURES = { healthDiagnostics: true, modelUsage: true, quotaLookup: true, backupMaintenance: true, taskNotifications: true, healthz: true, skillManager: true, subagentRoute: true, mobileAdaptation: true }
1095
1113
  const featureScope = ctx.settingsScope.bind({ namespace: NS })
1096
1114
  const featureSnapshot = () => featureScope.getSnapshot()
1097
1115
  const featureValue = () => Object.assign({}, DEFAULT_FEATURES, featureSnapshot().value || {})
@@ -2249,15 +2267,15 @@ window.__ModuleLoader__.load({
2249
2267
  quotaCreatePortal = null
2250
2268
  }
2251
2269
 
2252
- /** 窄视口判定:matchMedia 优先(可订阅变化),缺席时 innerWidth 兜底;
2253
- * window(SSR/测试)一律宽视口,维持圆环上方锚定弹层。断点 480px 与外壳窄容器一致。 */
2270
+ /** 移动视口判定:与 v0.30 整体适配共用 1023px 断点;matchMedia 优先,
2271
+ * 缺席时 innerWidth 兜底。无 window(SSR/测试)一律宽视口,维持圆环上方锚定。 */
2254
2272
  function quotaNarrowViewport() {
2255
2273
  if (typeof window === 'undefined' || window === null) return false
2256
2274
  if (typeof window.matchMedia === 'function') {
2257
- const query = window.matchMedia('(max-width: 480px)')
2275
+ const query = window.matchMedia('(max-width: 1023px)')
2258
2276
  return typeof query.matches === 'boolean' ? query.matches : false
2259
2277
  }
2260
- return typeof window.innerWidth === 'number' && window.innerWidth <= 480
2278
+ return typeof window.innerWidth === 'number' && window.innerWidth <= 1023
2261
2279
  }
2262
2280
 
2263
2281
  function QuotaRing(props) {
@@ -2284,12 +2302,12 @@ window.__ModuleLoader__.load({
2284
2302
  const [open, setOpen] = useState(false)
2285
2303
  const rootRef = useRef(null)
2286
2304
  const panelRef = useRef(null)
2287
- // 窄视口(手机竖屏):弹层 portal 到 body 视口居中;宽视口保持圆环上方锚定。
2305
+ // 移动视口:弹层 portal 到 body,在对话区域下半部保持水平居中;宽视口保持圆环上方锚定。
2288
2306
  // matchMedia change 订阅让旋转/拖宽窗口时弹层几何实时迁移,open 态不丢。
2289
2307
  const [narrow, setNarrow] = useState(quotaNarrowViewport)
2290
2308
  useEffect(() => {
2291
2309
  if (typeof window === 'undefined' || window === null || typeof window.matchMedia !== 'function') return undefined
2292
- const query = window.matchMedia('(max-width: 480px)')
2310
+ const query = window.matchMedia('(max-width: 1023px)')
2293
2311
  const sync = () => setNarrow(query.matches)
2294
2312
  sync()
2295
2313
  if (typeof query.addEventListener === 'function') query.addEventListener('change', sync)
@@ -2361,8 +2379,8 @@ window.__ModuleLoader__.load({
2361
2379
  ? React.createElement('div', { style: { marginTop: '8px', fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)' } },
2362
2380
  row.refreshing === true ? translate('quota.refreshing') : translate('quota.updated', { time: formatClockTime(row.fetchedAt) }))
2363
2381
  : null
2364
- // 面板先构造、再决定挂载方式:窄视口 portal 到 document.body 后 fixed 视口居中
2365
- // (根因见 quotaCreatePortal 处注释);宽视口或 react-dom 缺席时锚定圆环上方(原行为)。
2382
+ // 面板先构造、再决定挂载方式:移动视口 portal 到 document.body 后 fixed
2383
+ // 水平居中、垂直中心下移到屏幕高度 75%;宽视口或 react-dom 缺席时锚定圆环上方。
2366
2384
  const centered = open && narrow && quotaCreatePortal !== null
2367
2385
  && typeof document !== 'undefined' && document.body !== null && document.body !== undefined
2368
2386
  const triggerNode = React.createElement('button', {
@@ -2393,7 +2411,7 @@ window.__ModuleLoader__.load({
2393
2411
  'aria-label': translate('quota.panel.title'),
2394
2412
  'data-testid': 'quota-ring-panel',
2395
2413
  style: centered
2396
- ? { position: 'fixed', left: '50%', top: '50%', transform: 'translate(-50%, -50%)', zIndex: 1000, boxSizing: 'border-box', width: 'min(280px, calc(100vw - 32px))', maxHeight: 'calc(100dvh - 96px)', overflowY: 'auto', padding: '12px', borderRadius: '12px', background: 'var(--dsw-specific-menu)', border: '1px solid var(--dsw-alias-border-inverted)', boxShadow: 'var(--dsw-shadow-lv3)' }
2414
+ ? { position: 'fixed', left: '50%', top: '75%', transform: 'translate(-50%, -50%)', zIndex: 1000, boxSizing: 'border-box', width: 'min(280px, calc(100vw - 32px))', maxHeight: 'min(560px, calc(100dvh - 176px))', overflowY: 'auto', padding: '12px', borderRadius: '12px', background: 'var(--dsw-specific-menu)', border: '1px solid var(--dsw-alias-border-inverted)', boxShadow: 'var(--dsw-shadow-lv3)' }
2397
2415
  : { position: 'absolute', bottom: 'calc(100% + 8px)', right: 0, zIndex: 100, boxSizing: 'border-box', width: '240px', padding: '12px', borderRadius: '12px', background: 'var(--dsw-specific-menu)', border: '1px solid var(--dsw-alias-border-inverted)', boxShadow: 'var(--dsw-shadow-lv3)' },
2398
2416
  },
2399
2417
  React.createElement('div', { style: { display: 'flex', alignItems: 'baseline', gap: '6px' } },
@@ -2470,7 +2488,7 @@ window.__ModuleLoader__.load({
2470
2488
  }, React.createElement('path', { d: 'M3 5.25 7 9l4-3.75', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round' }))),
2471
2489
  open ? React.createElement('div', { style: { margin: '0 16px', padding: '12px 0 8px', borderTop: '1px solid var(--dsw-alias-border-l2)' } },
2472
2490
  React.createElement('div', { style: { fontSize: '12px', fontWeight: 700 } }, translate('features.optional')),
2473
- ['healthDiagnostics', 'modelUsage', 'quotaLookup', 'backupMaintenance', 'taskNotifications', 'skillManager', 'subagentRoute'].map(row),
2491
+ ['healthDiagnostics', 'modelUsage', 'quotaLookup', 'backupMaintenance', 'taskNotifications', 'skillManager', 'subagentRoute', 'mobileAdaptation'].map(row),
2474
2492
  React.createElement('div', { style: { fontSize: '12px', fontWeight: 700, marginTop: '8px', paddingTop: '10px', borderTop: '1px solid var(--dsw-alias-border-l1)' } }, translate('features.external')),
2475
2493
  row('healthz'),
2476
2494
  !writable ? React.createElement('p', { style: { margin: '6px 0 0', fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)' } }, translate('features.readOnly')) : null) : null)
@@ -4481,6 +4499,532 @@ window.__ModuleLoader__.load({
4481
4499
  const unsubscribe = featureScope.subscribe(sync)
4482
4500
  return () => { unsubscribe(); if (dispose !== null) dispose() }
4483
4501
  })
4502
+
4503
+ // ─── 移动端适配引擎(v0.30)─────────────────────────────────────────
4504
+ // 断点与官方外壳一致取 <1024px(AppFrame 的 SIDEBAR_AUTO_COLLAPSE)。
4505
+ // 全部规则作用域于 html[data-dshsvc-mobile] 属性下;抽屉开合走官方
4506
+ // ctx.layout 服务,不重挂宿主 DOM。桌面 ≥1024px 或功能关闭时零效果。
4507
+ const MOBILE_ACTIVE_QUERY = '(max-width: 1023px)'
4508
+ const MOBILE_DEBUG_PARAM = 'dshsvc-mobile-debug'
4509
+ const MOBILE_CSS = `
4510
+ /* 侧栏/详情列改 absolute 后会退出 grid 排版流,中列会被自动放置进第 1 轨
4511
+ (0px)而整屏变黑 —— 三列必须用 grid-column 显式钉位,绝不依赖子元素顺序。 */
4512
+ html[data-dshsvc-mobile] [data-dshsvc-frame] { grid-template-columns: 0px minmax(0, 1fr) 0px !important; }
4513
+ html[data-dshsvc-mobile] [data-dshsvc-sidebar] { grid-column: 1 !important; grid-row: 1 !important; }
4514
+ html[data-dshsvc-mobile] [data-dshsvc-center] { grid-column: 2 !important; grid-row: 1 !important; }
4515
+ html[data-dshsvc-mobile] [data-dshsvc-details] { grid-column: 3 !important; grid-row: 1 !important; }
4516
+ /* 侧栏/详情列 → overlay 抽屉。两条铁律:
4517
+ ① 隐藏禁用 transform —— 设置模态(未 portal)长在本列子树里,
4518
+ transform 会成为其 fixed 定位的包含块;
4519
+ ② 离屏偏移必须用 vw 长度而非百分比 —— 这些列带显式 grid-column 钉位
4520
+ (修中列黑屏所需),绝对定位子项的包含块会变成那格 grid area(0px 宽),
4521
+ 百分比对 0 取值全变 0,元素被超约束解算推回屏内盖住会话。 */
4522
+ html[data-dshsvc-mobile] [data-dshsvc-sidebar] {
4523
+ position: absolute !important;
4524
+ top: 0 !important;
4525
+ bottom: 0 !important;
4526
+ left: calc(-100vw - 24px) !important;
4527
+ /* 外壳 sidebar slot 的原生展开内容固定 280px;外层也必须同宽,否则右侧
4528
+ 会露出一条只有 sidebarCol 背景、没有内容的空带。窄于 280px 时按视口裁切。 */
4529
+ width: min(100vw, 280px) !important;
4530
+ border-right: none !important;
4531
+ z-index: 32;
4532
+ transition: left var(--ds-transition-duration-slow, .25s) var(--ds-ease-in-out, ease);
4533
+ }
4534
+ html[data-dshsvc-mobile] [data-dshsvc-frame]:not([data-sidebar-collapsed]) [data-dshsvc-sidebar] {
4535
+ left: 0 !important;
4536
+ box-shadow: 8px 0 32px rgba(0, 0, 0, .22);
4537
+ }
4538
+ /* 详情列(预览/文件树)→ 移动端永久离屏。官方 computeColumns 在窄视口恒为
4539
+ 0 宽(原生手机本就不显示该列),抽屉化只会用空态盖住会话且关闭路径
4540
+ 与 backdrop/抽屉状态纠缠(真机反馈)。引擎在激活与属性观察时会自愈式
4541
+ closeDetails(),保证 store 也是干净的关闭态。 */
4542
+ html[data-dshsvc-mobile] [data-dshsvc-details] {
4543
+ position: absolute !important;
4544
+ top: 0 !important;
4545
+ bottom: 0 !important;
4546
+ right: calc(-100vw - 24px) !important;
4547
+ width: min(92vw, 420px) !important;
4548
+ z-index: 30;
4549
+ }
4550
+ /* 模态 → 全屏面板:外壳 portal 根是 fixed inset:0 flex 容器(无 transform,
4551
+ 无 containing-block 陷阱),dialog 改 absolute 四边拉满即可。
4552
+ 真机反馈:底部 sheet 顶部留空难看 → 顶部贴顶、圆角归零;
4553
+ 刘海/home 条遮挡由 dialog 自身 env() padding 补回。 */
4554
+ html[data-dshsvc-mobile] div[role="presentation"]:has(> [role="dialog"][aria-modal="true"]) {
4555
+ padding: 0 !important;
4556
+ }
4557
+ html[data-dshsvc-mobile] [role="dialog"][aria-modal="true"] {
4558
+ position: absolute !important;
4559
+ top: 0 !important;
4560
+ right: 0 !important;
4561
+ bottom: 0 !important;
4562
+ left: 0 !important;
4563
+ margin: 0 !important;
4564
+ width: 100% !important;
4565
+ max-width: none !important;
4566
+ max-height: none !important;
4567
+ height: 100% !important;
4568
+ border-radius: 0 !important;
4569
+ overflow: hidden auto !important;
4570
+ flex-direction: column !important;
4571
+ padding-top: env(safe-area-inset-top, 0px) !important;
4572
+ padding-bottom: env(safe-area-inset-bottom, 0px) !important;
4573
+ }
4574
+ /* 插件市场(dshmarket)在 ≤560px 媒体查询里把设置导航 display:none 藏掉
4575
+ 独占面板(其自有 CSS:[role=dialog]:has([data-dsh-market-root])>nav),
4576
+ 手机上会把用户困在市场分区 —— 用更高优先级把横滑标签条顶回来。 */
4577
+ html[data-dshsvc-mobile] [role="dialog"]:has([data-dsh-market-root]) > nav { display: flex !important; }
4578
+ /* 设置页标签条:nav 从 188px 竖列变顶部横滑条(宽度与列向必须显式推翻)。
4579
+ 右侧留 52px 给钉到角落的关闭钮。 */
4580
+ html[data-dshsvc-mobile] [role="dialog"] nav {
4581
+ flex-direction: row !important;
4582
+ align-items: center !important;
4583
+ gap: 6px !important;
4584
+ width: auto !important;
4585
+ flex: none !important;
4586
+ padding: 8px 52px 0 12px !important;
4587
+ overflow-x: auto !important;
4588
+ overflow-y: hidden !important;
4589
+ border-right: none !important;
4590
+ border-bottom: 1px solid var(--dsw-alias-border-l2);
4591
+ }
4592
+ html[data-dshsvc-mobile] [role="dialog"] nav > div { flex: none !important; }
4593
+ html[data-dshsvc-mobile] [role="dialog"] [class*="navList"] { flex-direction: row !important; }
4594
+ html[data-dshsvc-mobile] [role="dialog"] [class*="navCell"] { white-space: nowrap !important; flex: none !important; }
4595
+ /* 关闭钮钉到设置页右上角并压到面板顶层(原生在内容区头行、导航条下方,
4596
+ 会被横滑条/内容重叠遮挡——用户点名要顶层);加不透明圆形底衬保证任何
4597
+ 内容上都可辨识,底衬与钮同节点、随之置顶。类哈希 VOzbGW_ 取自
4598
+ dsh-client-ui-settings-general SettingsRoot.module.css(rc.2),升级需复核 */
4599
+ html[data-dshsvc-mobile] [role="dialog"][aria-modal="true"] [class*="VOzbGW_close"] {
4600
+ position: absolute !important;
4601
+ top: calc(env(safe-area-inset-top, 0px) + 9px) !important;
4602
+ right: 10px !important;
4603
+ z-index: 60;
4604
+ background-color: var(--dsw-specific-menu, var(--dsw-alias-bg-layer-1)) !important;
4605
+ border: 1px solid var(--dsw-alias-border-l2) !important;
4606
+ border-radius: 999px !important;
4607
+ box-shadow: var(--dsw-shadow-lv2, 0 4px 12px rgba(0, 0, 0, .12)) !important;
4608
+ }
4609
+ /* 设置/任意模态打开时藏抽屉钮:抽屉列自带 z-index:32 层叠上下文会把模态的
4610
+ z1000 封顶在 32,body 级 z33 的钮反而浮在设置页上(真机实测)。:has 不支持时
4611
+ 退化为旧行为(钮仍显示),无副作用。 */
4612
+ html[data-dshsvc-mobile] body:has([role="dialog"][aria-modal="true"]) [data-dshsvc-fab] {
4613
+ display: none !important;
4614
+ }
4615
+ /* composer 底行单行紧凑:外壳原生 flex-wrap:wrap 在窄屏把图标/模型名折成两行。
4616
+ 收紧间距 + 禁换行 + 最宽触发钮限宽省略。类哈希 uV2eYG_/Sh0Q9G_/pXSMma_ 取自
4617
+ dsh-client-ui-conversation composer(rc.2),升级需复核。 */
4618
+ html[data-dshsvc-mobile] [class*="uV2eYG_row"] {
4619
+ flex-wrap: nowrap !important;
4620
+ column-gap: 4px !important;
4621
+ padding-left: 10px !important;
4622
+ padding-right: 10px !important;
4623
+ }
4624
+ html[data-dshsvc-mobile] [class*="uV2eYG_row"] > * { min-width: 0 !important; }
4625
+ html[data-dshsvc-mobile] [class*="uV2eYG_tools"],
4626
+ html[data-dshsvc-mobile] [class*="uV2eYG_modes"],
4627
+ html[data-dshsvc-mobile] [class*="uV2eYG_trailing"] { gap: 6px !important; min-width: 0 !important; }
4628
+ html[data-dshsvc-mobile] [class*="uV2eYG_trailing"] { margin-left: auto !important; }
4629
+ html[data-dshsvc-mobile] [class*="Sh0Q9G_trigger"] { max-width: 38vw !important; }
4630
+ html[data-dshsvc-mobile] [class*="pXSMma_workspace"] { max-width: 30vw !important; }
4631
+ /* 工作区侧板(fixed z25 层内的 nArs4W_panel z40)开屏后会盖住它自己的外部
4632
+ 开关钮(tab bar 行 nArs4W_toggleButton)——手机上抽屉一开就再没有任何
4633
+ 可点的关闭入口(真机反馈「关不上」本体)。把开关钮提到面板之上,
4634
+ 恢复「同一颗钮开/关」语义。 */
4635
+ html[data-dshsvc-mobile] [class*="nArs4W_toggleButton"] {
4636
+ position: relative !important;
4637
+ z-index: 45 !important;
4638
+ }
4639
+ /* 会话底部统计条:外壳原生 white-space:nowrap + ellipsis 截断(StatsLine),
4640
+ 改横向滑动查看全文。类哈希 FJxK0a_ 取自 dsh-client-ui-conversation/
4641
+ StatsLine.module.css(0.1.1-rc.2),外壳升级后需复核。 */
4642
+ html[data-dshsvc-mobile] [class*="FJxK0a_root"] {
4643
+ overflow-x: auto !important;
4644
+ overflow-y: hidden !important;
4645
+ text-overflow: clip !important;
4646
+ -webkit-overflow-scrolling: touch;
4647
+ scrollbar-width: none !important;
4648
+ }
4649
+ html[data-dshsvc-mobile] [class*="FJxK0a_root"]::-webkit-scrollbar { display: none !important; }
4650
+ /* 左上角抽屉钮:悬停/按压用外壳交互底色;会话头部预留按钮位防遮面包屑 */
4651
+ html[data-dshsvc-mobile] [data-dshsvc-fab]:hover,
4652
+ html[data-dshsvc-mobile] [data-dshsvc-fab]:active { background: var(--dsw-alias-interactive-bg-hover) !important; }
4653
+ html[data-dshsvc-mobile] [data-dshsvc-frame] > :nth-child(2) header { padding-left: 46px !important; }
4654
+ /* 刘海安全区:viewport-fit=cover 后由 env() 补回遮挡区 */
4655
+ html[data-dshsvc-mobile] body {
4656
+ padding-top: env(safe-area-inset-top, 0px);
4657
+ padding-bottom: env(safe-area-inset-bottom, 0px);
4658
+ box-sizing: border-box;
4659
+ }
4660
+ /* 双击缩放与 iOS 聚焦放大 */
4661
+ html[data-dshsvc-mobile] button,
4662
+ html[data-dshsvc-mobile] a,
4663
+ html[data-dshsvc-mobile] [role="button"],
4664
+ html[data-dshsvc-mobile] input,
4665
+ html[data-dshsvc-mobile] textarea,
4666
+ html[data-dshsvc-mobile] select { touch-action: manipulation; }
4667
+ html[data-dshsvc-mobile] input:not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not([type="reset"]),
4668
+ html[data-dshsvc-mobile] textarea { font-size: max(16px, 1em) !important; }
4669
+ /* composer 防挤压:保守规则,真机反馈后迭代 */
4670
+ html[data-dshsvc-mobile] [class*="toolbar" i] { flex-wrap: wrap !important; min-width: 0 !important; max-width: 100% !important; }
4671
+ html[data-dshsvc-mobile] [class*="composer" i] { min-width: 0 !important; max-width: 100% !important; }
4672
+ html[data-dshsvc-mobile] [class*="inputTriggers" i] > *,
4673
+ html[data-dshsvc-mobile] [class*="toolbar" i] button { flex: none !important; }
4674
+ `
4675
+
4676
+ function createMobileAdaptation() {
4677
+ const state = {
4678
+ active: false,
4679
+ mq: null,
4680
+ mqHandler: null,
4681
+ frameObserver: null,
4682
+ mountObserver: null,
4683
+ workspaceObserver: null,
4684
+ errorHandler: null,
4685
+ resizeHandler: null,
4686
+ styleTag: null,
4687
+ backdrop: null,
4688
+ fab: null,
4689
+ debugChip: null,
4690
+ debugEnabled: false,
4691
+ errorCount: 0,
4692
+ drawerOpen: false,
4693
+ detailsOpen: false,
4694
+ workspaceOpen: false,
4695
+ lastFabDisplay: null,
4696
+ lastBackdropDisplay: null,
4697
+ }
4698
+
4699
+ const layoutService = () => {
4700
+ try { return typeof ctx.get === 'function' ? ctx.get('layout') : undefined } catch (_) { return undefined }
4701
+ }
4702
+
4703
+ const syncSurfaces = () => {
4704
+ // 详情列移动端永久离屏(CSS),backdrop 只服务侧栏抽屉;
4705
+ // 若外部把详情打开(openDetails),这里自愈式收掉,避免空态盖屏。
4706
+ if (state.detailsOpen) {
4707
+ const layout = layoutService()
4708
+ if (layout !== undefined) {
4709
+ try { layout.closeDetails() } catch (_) {}
4710
+ }
4711
+ state.detailsOpen = false
4712
+ }
4713
+ // 抽屉开启时收起 FAB:关闭走外壳原生侧栏钮或外侧遮罩,绝不在
4714
+ // 抽屉面板上叠画第二套关闭件(真机反馈:
4715
+ // 那只会变成糊在侧栏 logo 上的不明物)。工作区侧板同理互斥。
4716
+ // 变化才写:innerHTML/display 若无条件重写会喂活 body 级观察器死循环。
4717
+ const nextDisplay = (state.drawerOpen || state.workspaceOpen) ? 'none' : 'flex'
4718
+ if (state.fab !== null && state.lastFabDisplay !== nextDisplay) {
4719
+ state.lastFabDisplay = nextDisplay
4720
+ state.fab.style.display = nextDisplay
4721
+ }
4722
+ const backdropNext = state.drawerOpen ? 'block' : 'none'
4723
+ if (state.backdrop !== null && state.lastBackdropDisplay !== backdropNext) {
4724
+ state.lastBackdropDisplay = backdropNext
4725
+ state.backdrop.style.display = backdropNext
4726
+ }
4727
+ if (state.debugEnabled && state.debugChip !== null) updateDebugChip()
4728
+ }
4729
+
4730
+ /** body 级监听的节流入口:同帧多次变更合并为一次读改(脏标记 + 单发调度),
4731
+ 回调内严格「无变化不写 DOM」,杜绝 观察器→写DOM→再触发 的自激循环。 */
4732
+ let syncScheduled = false
4733
+ const scheduleSync = () => {
4734
+ if (syncScheduled || !state.active) return
4735
+ syncScheduled = true
4736
+ // 定时器可能活过宿主环境/测试清理期——回调整体兜底,绝不外抛
4737
+ setTimeout(() => {
4738
+ syncScheduled = false
4739
+ if (!state.active) return
4740
+ try {
4741
+ readOverlayState()
4742
+ syncSurfaces()
4743
+ } catch (_) {}
4744
+ }, 50)
4745
+ // 追踪采样:外壳的显隐走过渡动画,50ms 那次读到的可能是动画前旧值;
4746
+ // 动画落定后再校准两次,否则 UI 僵在旧状态、要再碰一下屏幕才刷新。
4747
+ for (const delay of [400, 900]) {
4748
+ setTimeout(() => {
4749
+ if (!state.active) return
4750
+ try { readOverlayState(); syncSurfaces() } catch (_) {}
4751
+ }, delay)
4752
+ }
4753
+ }
4754
+
4755
+ const readOverlayState = () => {
4756
+ const frame = document.querySelector('[data-dshsvc-frame]')
4757
+ state.drawerOpen = frame !== null && !frame.hasAttribute('data-sidebar-collapsed')
4758
+ state.detailsOpen = frame !== null && !frame.hasAttribute('data-details-collapsed')
4759
+ // 工作区侧板(外壳 tab 系统 nArs4W_panel):可见 = 任一匹配节点解除
4760
+ // PanelHidden 且进入视口。注意 panelBody 等 同哈希同名 子串节点会混入
4761
+ // querySelector 匹配,必须逐个甄别取「或」,否则状态时对时错。
4762
+ state.workspaceOpen = false
4763
+ try {
4764
+ for (const el of document.querySelectorAll('[class*="nArs4W_panel"]')) {
4765
+ const cls = typeof el.className === 'string' ? el.className : ''
4766
+ if (/nArs4W_panelHidden/.test(cls)) continue
4767
+ const rect = el.getBoundingClientRect()
4768
+ if (rect.width > 0 && rect.left < window.innerWidth - 2 && getComputedStyle(el).visibility !== 'hidden') {
4769
+ state.workspaceOpen = true
4770
+ break
4771
+ }
4772
+ }
4773
+ } catch (_) { state.workspaceOpen = false }
4774
+ }
4775
+
4776
+ const FAB_OPEN_ICON =
4777
+ '<svg width="18" height="18" viewBox="0 0 16 16" fill="none" aria-hidden="true">' +
4778
+ '<rect x="1.75" y="2.75" width="12.5" height="10.5" rx="2" stroke="currentColor" stroke-width="1.5"/>' +
4779
+ '<line x1="6.25" y1="3.5" x2="6.25" y2="12.5" stroke="currentColor" stroke-width="1.5"/></svg>'
4780
+
4781
+ const updateDebugChip = () => {
4782
+ const chip = state.debugChip
4783
+ if (chip === null) return
4784
+ const onOff = (value) => (value ? t('mobile.debug.stateOn') : t('mobile.debug.stateOff'))
4785
+ chip.textContent = [
4786
+ `${t('mobile.debug.viewport')} ${window.innerWidth}×${window.innerHeight}`,
4787
+ `≤1023 ${onOff(state.active)}`,
4788
+ `${t('mobile.debug.drawer')} ${onOff(state.drawerOpen)}`,
4789
+ `${t('mobile.debug.details')} ${onOff(state.detailsOpen)}`,
4790
+ `${t('mobile.debug.errors')} ${state.errorCount}`,
4791
+ ].join(' · ')
4792
+ }
4793
+
4794
+ /** 给外壳三栏骨架打自有标记属性;找不到骨架(异常布局)时返回 false 跳过抽屉件。 */
4795
+ const tagShellFrame = () => {
4796
+ // overlayLayer 是 frame 的子元素且带官方 data-shell-overlay 属性(源码核实)
4797
+ const overlayLayer = document.querySelector('[data-shell-overlay]')
4798
+ const frame = overlayLayer !== null ? overlayLayer.parentNode : null
4799
+ if (frame === null || frame === document.documentElement) return false
4800
+ frame.setAttribute('data-dshsvc-frame', '')
4801
+ let sawSidebar = false
4802
+ let sawCenter = false
4803
+ let sawDetails = false
4804
+ for (const child of frame.children) {
4805
+ const className = typeof child.className === 'string' ? child.className : ''
4806
+ if (!sawSidebar && /sidebarCol/.test(className)) {
4807
+ child.setAttribute('data-dshsvc-sidebar', '')
4808
+ sawSidebar = true
4809
+ } else if (!sawCenter && /centerCol/.test(className)) {
4810
+ child.setAttribute('data-dshsvc-center', '')
4811
+ sawCenter = true
4812
+ } else if (!sawDetails && /detailsCol/.test(className)) {
4813
+ child.setAttribute('data-dshsvc-details', '')
4814
+ sawDetails = true
4815
+ }
4816
+ }
4817
+ return true
4818
+ }
4819
+
4820
+ /** 建 backdrop/FAB 并接上抽屉状态观察。外壳骨架就绪才算成功;
4821
+ 手机直接窄屏冷加载时骨架往往还没挂载(真机反馈:抽屉钮缺失 +
4822
+ 官方 rail 残留,就是这个竞态),失败则由 watchForShell 重试。 */
4823
+ const buildSurfaces = () => {
4824
+ if (state.fab !== null) return true
4825
+ if (!tagShellFrame()) return false
4826
+ state.backdrop = document.createElement('div')
4827
+ state.backdrop.setAttribute('data-dshsvc-backdrop', '')
4828
+ state.backdrop.setAttribute('aria-hidden', 'true')
4829
+ Object.assign(state.backdrop.style, {
4830
+ position: 'fixed', inset: '0', zIndex: '31', display: 'none',
4831
+ background: 'rgba(0,0,0,.42)', backdropFilter: 'blur(1px)',
4832
+ })
4833
+ state.backdrop.addEventListener('click', () => {
4834
+ const layout = layoutService()
4835
+ if (layout === undefined) return
4836
+ if (state.drawerOpen) layout.toggleSidebar()
4837
+ })
4838
+ document.body.appendChild(state.backdrop)
4839
+
4840
+ state.fab = document.createElement('button')
4841
+ state.fab.type = 'button'
4842
+ state.fab.setAttribute('data-dshsvc-fab', '')
4843
+ state.fab.setAttribute('aria-label', t('mobile.fab.label'))
4844
+ // 样式对齐外壳幽灵图标钮(设置关闭钮同族:28px 圆形、透明底、主题色),
4845
+ // 位置钉会话头部左上角(safe-area 感知),图标为侧栏面板开关。
4846
+ Object.assign(state.fab.style, {
4847
+ position: 'fixed',
4848
+ left: 'calc(env(safe-area-inset-left, 0px) + 10px)',
4849
+ top: 'calc(env(safe-area-inset-top, 0px) + 12px)',
4850
+ width: '32px', height: '32px', borderRadius: '16px', zIndex: '33', display: 'flex',
4851
+ alignItems: 'center', justifyContent: 'center',
4852
+ border: 'none', background: 'transparent', color: 'var(--dsw-alias-label-primary)',
4853
+ padding: '0', cursor: 'pointer', touchAction: 'manipulation',
4854
+ })
4855
+ state.fab.innerHTML =
4856
+ '<svg width="18" height="18" viewBox="0 0 16 16" fill="none" aria-hidden="true">' +
4857
+ '<rect x="1.75" y="2.75" width="12.5" height="10.5" rx="2" stroke="currentColor" stroke-width="1.5"/>' +
4858
+ '<line x1="6.25" y1="3.5" x2="6.25" y2="12.5" stroke="currentColor" stroke-width="1.5"/></svg>'
4859
+ // 标准 click 是唯一激活路径:浏览器会把一次触屏手势归一为一个 click,
4860
+ // 同时保留鼠标、键盘与辅助技术支持。不得在 pointerdown 提前翻转再按时间窗
4861
+ // 吞合成 click——主线程忙时 click 可晚到时间窗外,导致同一短按开后又关,
4862
+ // 表现成「必须长按才能打开」。touch-action:manipulation 已消除旧式点按延迟。
4863
+ state.fab.addEventListener('click', () => {
4864
+ const layout = layoutService()
4865
+ if (layout !== undefined) layout.toggleSidebar()
4866
+ })
4867
+ document.body.appendChild(state.fab)
4868
+ readOverlayState()
4869
+
4870
+ // 不在 sidebarCol 上代理关闭:侧栏右上角已有外壳原生 toggle。若祖先再监听
4871
+ // click,同一事件会先由按钮关闭、再冒泡触发第二次 toggle,结果立即重新打开。
4872
+ // 抽屉关闭路径只保留原生 toggle 与外侧 backdrop,两者职责互不重叠。
4873
+
4874
+ // 工作区侧板(nArs4W_panel,fixed z25 层)不受三栏属性驱动;
4875
+ // body 级监听只置脏标记、单发调度处理(绝不在回调里同步写 DOM,
4876
+ // 否则 写DOM→再触发→再写 的自激循环会打满主线程,页面永久转圈)
4877
+ try {
4878
+ state.workspaceObserver = new MutationObserver(() => { scheduleSync() })
4879
+ state.workspaceObserver.observe(document.body, { attributes: true, childList: true, subtree: true })
4880
+ } catch (_) {
4881
+ state.workspaceObserver = null
4882
+ }
4883
+
4884
+ // frame 属性观察是抽屉状态主驱动:同步处理(监听范围受控、无自激风险)
4885
+ state.frameObserver = new MutationObserver(() => {
4886
+ readOverlayState()
4887
+ syncSurfaces()
4888
+ })
4889
+ const frame = document.querySelector('[data-dshsvc-frame]')
4890
+ if (frame !== null) state.frameObserver.observe(frame, { attributes: true, attributeFilter: ['data-sidebar-collapsed', 'data-details-collapsed'] })
4891
+
4892
+ if (state.mountObserver !== null) { state.mountObserver.disconnect(); state.mountObserver = null }
4893
+ syncSurfaces()
4894
+ return true
4895
+ }
4896
+
4897
+ /** 外壳骨架晚挂载的重试观察:AppFrame 一进 DOM 就补建抽屉件。 */
4898
+ const watchForShell = () => {
4899
+ if (state.mountObserver !== null || typeof MutationObserver !== 'function') return
4900
+ try {
4901
+ state.mountObserver = new MutationObserver(() => { buildSurfaces() })
4902
+ state.mountObserver.observe(document.documentElement, { childList: true, subtree: true })
4903
+ } catch (_) {
4904
+ state.mountObserver = null
4905
+ }
4906
+ }
4907
+
4908
+ const ensureViewportCover = () => {
4909
+ for (const meta of document.querySelectorAll('meta[name="viewport"]')) {
4910
+ const content = meta.getAttribute('content') || ''
4911
+ if (/(^|,)\s*viewport-fit\s*=/.test(content)) continue
4912
+ meta.setAttribute('content', content.trim() === '' ? 'viewport-fit=cover' : content.trim() + ', viewport-fit=cover')
4913
+ }
4914
+ }
4915
+
4916
+ const activate = () => {
4917
+ if (state.active) return
4918
+ if (layoutService() === undefined) return // 无官方 layout 服务时不出交互件,避免不可收起的抽屉
4919
+ state.active = true
4920
+ // 全部规则的作用域开关:CSS 与 DOM 标记都以它为门
4921
+ document.documentElement.setAttribute('data-dshsvc-mobile', '')
4922
+ ensureViewportCover()
4923
+ state.styleTag = document.createElement('style')
4924
+ state.styleTag.dataset.plugin = '@gehennawu/dsh-service'
4925
+ state.styleTag.dataset.pluginCss = '@gehennawu/dsh-service/mobile.css'
4926
+ state.styleTag.textContent = MOBILE_CSS
4927
+ document.head.appendChild(state.styleTag)
4928
+ if (!buildSurfaces()) watchForShell()
4929
+
4930
+ let debugRequested = false
4931
+ try { debugRequested = new URLSearchParams(window.location.search).has(MOBILE_DEBUG_PARAM) } catch (_) {}
4932
+ if (debugRequested) {
4933
+ state.debugEnabled = true
4934
+ state.errorHandler = () => {
4935
+ state.errorCount += 1
4936
+ updateDebugChip()
4937
+ }
4938
+ window.addEventListener('error', state.errorHandler)
4939
+ state.resizeHandler = () => updateDebugChip()
4940
+ window.addEventListener('resize', state.resizeHandler)
4941
+ state.debugChip = document.createElement('div')
4942
+ state.debugChip.setAttribute('data-dshsvc-debug', '')
4943
+ Object.assign(state.debugChip.style, {
4944
+ position: 'fixed', left: '8px', bottom: 'calc(env(safe-area-inset-bottom, 0px) + 8px)',
4945
+ zIndex: '60', maxWidth: '94vw', padding: '4px 8px', borderRadius: '8px',
4946
+ font: '11px/1.5 var(--ds-font-family-code, monospace)',
4947
+ background: 'var(--dsw-alias-bg-layer-2, #333)', color: 'var(--dsw-alias-label-primary, #eee)',
4948
+ pointerEvents: 'none', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
4949
+ })
4950
+ state.debugChip.title = t('mobile.debug.title')
4951
+ document.body.appendChild(state.debugChip)
4952
+ updateDebugChip()
4953
+ }
4954
+ syncSurfaces()
4955
+ }
4956
+
4957
+ const deactivate = () => {
4958
+ if (!state.active) return
4959
+ state.active = false
4960
+ state.drawerOpen = false
4961
+ state.detailsOpen = false
4962
+ document.documentElement.removeAttribute('data-dshsvc-mobile')
4963
+ if (state.frameObserver !== null) { state.frameObserver.disconnect(); state.frameObserver = null }
4964
+ if (state.workspaceObserver !== null) { state.workspaceObserver.disconnect(); state.workspaceObserver = null }
4965
+ if (state.mountObserver !== null) { state.mountObserver.disconnect(); state.mountObserver = null }
4966
+ if (state.errorHandler !== null) window.removeEventListener('error', state.errorHandler)
4967
+ if (state.resizeHandler !== null) window.removeEventListener('resize', state.resizeHandler)
4968
+ state.errorHandler = null
4969
+ state.resizeHandler = null
4970
+ for (const el of [state.styleTag, state.backdrop, state.fab, state.debugChip]) {
4971
+ if (el !== null && el.isConnected) el.remove()
4972
+ }
4973
+ for (const el of document.querySelectorAll('[data-dshsvc-frame],[data-dshsvc-sidebar],[data-dshsvc-center],[data-dshsvc-details]')) {
4974
+ el.removeAttribute('data-dshsvc-frame')
4975
+ el.removeAttribute('data-dshsvc-sidebar')
4976
+ el.removeAttribute('data-dshsvc-center')
4977
+ el.removeAttribute('data-dshsvc-details')
4978
+ }
4979
+ state.styleTag = null
4980
+ state.backdrop = null
4981
+ state.fab = null
4982
+ state.debugChip = null
4983
+ state.debugEnabled = false
4984
+ }
4985
+
4986
+ const evaluate = () => {
4987
+ const want = featureEnabled('mobileAdaptation') && state.mmqMatches()
4988
+ if (want) activate()
4989
+ else deactivate()
4990
+ }
4991
+
4992
+ const dispose = () => {
4993
+ deactivate()
4994
+ if (state.mq !== null && state.mqHandler !== null) {
4995
+ if (typeof state.mq.removeEventListener === 'function') state.mq.removeEventListener('change', state.mqHandler)
4996
+ else if (typeof state.mq.removeListener === 'function') state.mq.removeListener('change', state.mqHandler)
4997
+ }
4998
+ state.mqHandler = null
4999
+ }
5000
+
5001
+ // matchMedia 惰性创建:测试环境可能只提供最小桩
5002
+ try {
5003
+ state.mq = window.matchMedia(MOBILE_ACTIVE_QUERY)
5004
+ } catch (_) {
5005
+ state.mq = null
5006
+ }
5007
+ state.mmqMatches = () => {
5008
+ try { return state.mq !== null && state.mq.matches === true } catch (_) { return false }
5009
+ }
5010
+ if (state.mq !== null) {
5011
+ state.mqHandler = () => evaluate()
5012
+ if (typeof state.mq.addEventListener === 'function') state.mq.addEventListener('change', state.mqHandler)
5013
+ else if (typeof state.mq.addListener === 'function') state.mq.addListener(state.mqHandler)
5014
+ }
5015
+
5016
+ return { evaluate, dispose }
5017
+ }
5018
+
5019
+ const mobileEngine = createMobileAdaptation()
5020
+ ctx.effect(() => {
5021
+ const unsubscribeFeatures = featureScope.subscribe(() => mobileEngine.evaluate())
5022
+ mobileEngine.evaluate()
5023
+ return () => {
5024
+ unsubscribeFeatures()
5025
+ mobileEngine.dispose()
5026
+ }
5027
+ }, 'dsh-service mobile adaptation')
4484
5028
  }
4485
5029
 
4486
5030
  exports.inject = inject
package/index.js CHANGED
@@ -9,6 +9,8 @@ import { basename, dirname, join, relative, resolve, sep } from 'node:path'
9
9
  import { createRequire } from 'node:module'
10
10
  import { fileURLToPath } from 'node:url'
11
11
  import https from 'node:https'
12
+ import { brotliCompress, constants as zlibConstants, gzip } from 'node:zlib'
13
+ import { ServerResponse as NodeServerResponse } from 'node:http'
12
14
  import z from '@deepseek-ai/schemastery'
13
15
 
14
16
  const require = createRequire(import.meta.url)
@@ -26,6 +28,7 @@ const DEFAULT_FEATURE_SETTINGS = Object.freeze({
26
28
  healthz: true,
27
29
  skillManager: true,
28
30
  subagentRoute: true,
31
+ mobileAdaptation: true,
29
32
  })
30
33
  const FeatureSettingsSchema = z.object({
31
34
  healthDiagnostics: z.boolean().default(true),
@@ -36,6 +39,7 @@ const FeatureSettingsSchema = z.object({
36
39
  healthz: z.boolean().default(true),
37
40
  skillManager: z.boolean().default(true),
38
41
  subagentRoute: z.boolean().default(true),
42
+ mobileAdaptation: z.boolean().default(true),
39
43
  })
40
44
  const NPM_REGISTRY = 'https://registry.npmjs.org/'
41
45
  const MAX_NPM_RESPONSE_BYTES = 256 * 1024
@@ -3103,6 +3107,261 @@ function resolveSubagentInjection(request, config, options = {}) {
3103
3107
  }
3104
3108
 
3105
3109
 
3110
+ // ── 移动端适配·宿主半(v0.30):大 JSON 响应透明压缩 ─────────────────────────
3111
+ // 长会话的 history JSON 可达数十 MB,手机网络下首屏极慢。给 http.ServerResponse.prototype
3112
+ // 打补丁:JSON 响应延迟到 end 收尾,按请求 Accept-Encoding 选 br > gzip 异步压缩;
3113
+ // 小体积与其他 content-type 字节级透传。插件卸载或功能关闭时经 disposer 还原原型。
3114
+ // 设计边界:ndjson / event-stream 这类增量流即使带 json 字样也绝不缓冲;
3115
+ // 不经 writeHead 显式带头对象的响应(statusCode + setHeader、Node 隐式头)识别不到,
3116
+ // 一律透传。
3117
+
3118
+ /** 不值得压缩的下限:更小的 JSON 按原始头字节级回放。 */
3119
+ const MOBILE_COMPRESS_MIN_BYTES = 4 * 1024
3120
+ /** 超过该体积跳过压缩:避免「原文 + 压缩结果」双份内存尖峰。 */
3121
+ const MOBILE_COMPRESS_MAX_BYTES = 64 * 1024 * 1024
3122
+ const MOBILE_BROTLI_QUALITY = 5
3123
+ const MOBILE_GZIP_LEVEL = 6
3124
+
3125
+ /**
3126
+ * content-type 是否值得走延迟压缩路径:JSON 且非增量流。
3127
+ * @param {unknown} contentType - 响应头 content-type 原始值(可空)。
3128
+ * @returns {boolean} true 表示可以缓冲到 end 再决定。
3129
+ */
3130
+ export function isCompressibleJsonType(contentType) {
3131
+ const value = String(contentType ?? '').toLowerCase()
3132
+ if (!value.includes('json')) return false
3133
+ // ndjson / event-stream 是持续增量流,整体缓冲会破坏消费语义。
3134
+ return !value.includes('ndjson') && !value.includes('event-stream')
3135
+ }
3136
+
3137
+ /**
3138
+ * 从 Accept-Encoding 请求头选编解码器;br 优先于 gzip。
3139
+ * @param {unknown} acceptEncoding - 请求头原值(可空)。
3140
+ * @returns {'br'|'gzip'|null} 客户端不接受的编码时返回 null(透传)。
3141
+ */
3142
+ export function pickCompressionEncoding(acceptEncoding) {
3143
+ const accepted = String(acceptEncoding ?? '').toLowerCase()
3144
+ if (/\bbr\b/.test(accepted)) return 'br'
3145
+ if (/\bgzip\b/.test(accepted)) return 'gzip'
3146
+ return null
3147
+ }
3148
+
3149
+ /**
3150
+ * 向既有 Vary 值追加一个 token;已有(大小写不敏感)则原样返回。
3151
+ * @param {unknown} existing - 现有 Vary 头值。
3152
+ * @param {string} token - 要追加的 token。
3153
+ * @returns {string} 合并后的 Vary 值。
3154
+ */
3155
+ export function appendVaryToken(existing, token) {
3156
+ const current = existing === null || existing === undefined ? '' : String(existing)
3157
+ if (current.toLowerCase().includes(token.toLowerCase())) return current
3158
+ return current === '' ? token : `${current}, ${token}`
3159
+ }
3160
+
3161
+ /**
3162
+ * 用 zlib 异步压缩一个完整 body(不阻塞事件循环)。
3163
+ * @param {Buffer} body - 待压缩字节。
3164
+ * @param {'br'|'gzip'} encoding - 目标编码。
3165
+ * @returns {Promise<Buffer>} 压缩结果。
3166
+ */
3167
+ function compressBodyAsync(body, encoding) {
3168
+ return new Promise((resolvePromise, rejectPromise) => {
3169
+ const settle = (error, result) => (error ? rejectPromise(error) : resolvePromise(result))
3170
+ if (encoding === 'br') {
3171
+ brotliCompress(body, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: MOBILE_BROTLI_QUALITY } }, settle)
3172
+ } else {
3173
+ gzip(body, { level: MOBILE_GZIP_LEVEL }, settle)
3174
+ }
3175
+ })
3176
+ }
3177
+
3178
+ /**
3179
+ * 把 write/end 缓冲期收到的 chunk 归一化为 Buffer 追加进 pending。
3180
+ * @param {{chunks: Buffer[]}} pending - 延迟响应状态。
3181
+ * @param {unknown} chunk - write/end 的 chunk 参数。
3182
+ * @param {string|undefined} encoding - Node 流编码参数(字符串 chunk 时生效)。
3183
+ * @returns {void}
3184
+ */
3185
+ function bufferPendingChunk(pending, chunk, encoding) {
3186
+ if (chunk === null || chunk === undefined) return
3187
+ if (Buffer.isBuffer(chunk)) pending.chunks.push(chunk)
3188
+ else if (ArrayBuffer.isView(chunk)) pending.chunks.push(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength))
3189
+ else if (typeof chunk === 'string') pending.chunks.push(Buffer.from(chunk, encoding || 'utf8'))
3190
+ else pending.chunks.push(Buffer.from(String(chunk)))
3191
+ }
3192
+
3193
+ /**
3194
+ * 解析 Node write/end 的可选参数序列(encoding 字符串 / callback)。
3195
+ * @param {unknown[]} rest - 除 chunk 外的剩余参数。
3196
+ * @returns {{encoding?: string, callbacks: Function[]}} 解析结果。
3197
+ */
3198
+ function parseStreamRestArgs(rest) {
3199
+ const parsed = { callbacks: [] }
3200
+ for (const item of rest) {
3201
+ if (typeof item === 'string') parsed.encoding = item
3202
+ else if (typeof item === 'function') parsed.callbacks.push(item)
3203
+ }
3204
+ return parsed
3205
+ }
3206
+
3207
+ /**
3208
+ * 以暂存的原始实参回放 writeHead(透传路径:头集合与调用方所写完全一致)。
3209
+ * @param {import('node:http').ServerResponse} res - 目标响应。
3210
+ * @param {Function} originalWriteHead - 未打补丁的 writeHead。
3211
+ * @param {unknown[]} storedArgs - 首次 writeHead 的实参副本。
3212
+ * @returns {import('node:http').ServerResponse} writeHead 返回值。
3213
+ */
3214
+ function replayStoredWriteHead(res, originalWriteHead, storedArgs) {
3215
+ if (storedArgs.length === 0) return originalWriteHead.call(res, 200)
3216
+ return originalWriteHead.apply(res, storedArgs)
3217
+ }
3218
+
3219
+ /**
3220
+ * 回放 writeHead,但把头集合替换为压缩路径的最终头对象。
3221
+ * @param {import('node:http').ServerResponse} res - 目标响应。
3222
+ * @param {Function} originalWriteHead - 未打补丁的 writeHead。
3223
+ * @param {unknown[]} storedArgs - 首次 writeHead 的实参副本。
3224
+ * @param {Record<string, unknown>} headers - 最终头集合(含压缩相关头)。
3225
+ * @returns {import('node:http').ServerResponse} writeHead 返回值。
3226
+ */
3227
+ function replayStoredWriteHeadWithHeaders(res, originalWriteHead, storedArgs, headers) {
3228
+ const args = storedArgs.slice()
3229
+ if (typeof args[1] === 'string') args[2] = headers
3230
+ else args[1] = headers
3231
+ return originalWriteHead.apply(res, args)
3232
+ }
3233
+
3234
+ /**
3235
+ * 安装大 JSON 响应透明压缩补丁。进程内单例:重复安装返回既有 disposer。
3236
+ * @returns {() => void} 还原原型方法的 disposer(身份校验,只摘自己的补丁)。
3237
+ */
3238
+ export function installMobileResponseCompression() {
3239
+ const proto = NodeServerResponse.prototype
3240
+ // 已装补丁的守卫放在 ensure 层;此处仍做二次防御,避免测试直调时叠加。
3241
+ if (proto.writeHead?.name === 'mobileCompressWriteHead') return () => {}
3242
+ const originalWriteHead = proto.writeHead
3243
+ const originalWrite = proto.write
3244
+ const originalEnd = proto.end
3245
+
3246
+ /** @type {WeakMap<import('node:http').ServerResponse, object>} 每响应延迟状态,仅 JSON 响应收尾前存在。 */
3247
+ const pendingByResponse = new WeakMap()
3248
+
3249
+ function mobileCompressWriteHead(...args) {
3250
+ try {
3251
+ const headerArg = typeof args[1] === 'string' ? args[2] : args[1]
3252
+ // 只有「显式携带头对象」的 writeHead 才考虑延迟:Node 的隐式头路径
3253
+ // (_implicitHeader → writeHead(statusCode)) 也走这里,若延迟会让原始
3254
+ // end 在未发头的 socket 上直写 body,破坏线上的帧结构。
3255
+ // setHeader 风格的响应因此保持透传(fail-open)。
3256
+ if (headerArg === undefined || headerArg === null || typeof headerArg !== 'object') {
3257
+ return originalWriteHead.apply(this, args)
3258
+ }
3259
+ // 判定用头集合 = 当前已 setHeader 的 + 本次 writeHead 实参里的(后者覆盖前者),
3260
+ // 让「setHeader 后再 writeHead(200, {…})」这类写法也能被识别。
3261
+ const merged = { ...this.getHeaders(), ...headerArg }
3262
+ const encoding = pickCompressionEncoding(this.req?.headers?.['accept-encoding'])
3263
+ if (
3264
+ encoding === null ||
3265
+ merged['content-encoding'] !== undefined ||
3266
+ !isCompressibleJsonType(merged['content-type'])
3267
+ ) return originalWriteHead.apply(this, args)
3268
+ // 延迟收尾:先不发头、不落盘,等 end 时按实际体积决定压缩还是原样回放。
3269
+ pendingByResponse.set(this, { writeHeadArgs: args.slice(), chunks: [], encoding })
3270
+ return this
3271
+ } catch (_) {
3272
+ // 判定阶段的任何意外都退回透传,不影响宿主原有响应行为。
3273
+ return originalWriteHead.apply(this, args)
3274
+ }
3275
+ }
3276
+
3277
+ function mobileCompressWrite(chunk, ...rest) {
3278
+ const pending = pendingByResponse.get(this)
3279
+ if (pending === undefined) return originalWrite.call(this, chunk, ...rest)
3280
+ const { encoding, callbacks } = parseStreamRestArgs(rest)
3281
+ bufferPendingChunk(pending, chunk, encoding)
3282
+ // 缓冲期的 write 回调立即以成功触发:这些 JSON 路由无人依赖背压/drain 时序,
3283
+ // 与其让调用方挂在一个永不触发的回调上,不如语义上视作「已接收」。
3284
+ for (const callback of callbacks) {
3285
+ try { callback(null) } catch (_) {}
3286
+ }
3287
+ return true
3288
+ }
3289
+
3290
+ function mobileCompressEnd(chunk, ...rest) {
3291
+ const pending = pendingByResponse.get(this)
3292
+ if (pending === undefined) {
3293
+ return chunk === undefined
3294
+ ? originalEnd.apply(this, rest)
3295
+ : originalEnd.call(this, chunk, ...rest)
3296
+ }
3297
+ pendingByResponse.delete(this)
3298
+ if (chunk !== undefined) bufferPendingChunk(pending, chunk, parseStreamRestArgs(rest).encoding)
3299
+ const [endCallback] = parseStreamRestArgs(rest).callbacks
3300
+ const body = Buffer.concat(pending.chunks)
3301
+
3302
+ // 透传收尾:原始 writeHead 实参 + 原始 body,字节级等价于未打补丁的行为。
3303
+ const finishPassthrough = () => {
3304
+ replayStoredWriteHead(this, originalWriteHead, pending.writeHeadArgs)
3305
+ if (body.length === 0) return originalEnd.call(this, endCallback)
3306
+ return originalEnd.call(this, body, endCallback)
3307
+ }
3308
+
3309
+ if (body.length < MOBILE_COMPRESS_MIN_BYTES || body.length > MOBILE_COMPRESS_MAX_BYTES) return finishPassthrough()
3310
+
3311
+ // 最终头集合 = 已 setHeader 的 + 暂存 writeHead 实参里的(后者覆盖前者,符合
3312
+ // Node writeHead 语义)。注意补丁延迟了原始 writeHead,getHeaders() 里不会有
3313
+ // 实参头的身影,必须显式并进来;writeHead 之后又 setHeader 的路径同样兼容。
3314
+ const storedHeaderArg = typeof pending.writeHeadArgs[1] === 'string' ? pending.writeHeadArgs[2] : pending.writeHeadArgs[1]
3315
+ const finalHeaders = {
3316
+ ...this.getHeaders(),
3317
+ ...(storedHeaderArg && typeof storedHeaderArg === 'object' ? storedHeaderArg : {}),
3318
+ }
3319
+ if (!isCompressibleJsonType(finalHeaders['content-type'])) return finishPassthrough()
3320
+
3321
+ finalHeaders['content-encoding'] = pending.encoding
3322
+ delete finalHeaders['content-length']
3323
+ finalHeaders.vary = appendVaryToken(finalHeaders.vary, 'Accept-Encoding')
3324
+
3325
+ compressBodyAsync(body, pending.encoding).then((compressed) => {
3326
+ finalHeaders['content-length'] = compressed.length
3327
+ replayStoredWriteHeadWithHeaders(this, originalWriteHead, pending.writeHeadArgs, finalHeaders)
3328
+ originalWrite.call(this, compressed)
3329
+ originalEnd.call(this, endCallback)
3330
+ }, finishPassthrough)
3331
+ return this
3332
+ }
3333
+
3334
+ proto.writeHead = mobileCompressWriteHead
3335
+ proto.write = mobileCompressWrite
3336
+ proto.end = mobileCompressEnd
3337
+ return () => {
3338
+ if (proto.writeHead === mobileCompressWriteHead) proto.writeHead = originalWriteHead
3339
+ if (proto.write === mobileCompressWrite) proto.write = originalWrite
3340
+ if (proto.end === mobileCompressEnd) proto.end = originalEnd
3341
+ }
3342
+ }
3343
+
3344
+ let activeMobileCompressionDispose = null
3345
+
3346
+ /**
3347
+ * 进程内单例入口:确保压缩补丁处于安装状态,返回用于还原的 disposer。
3348
+ * 每次调用都返回独立的托管包装:任一包装释放都会真正还原补丁并清掉单例标记,
3349
+ * 后续再 ensure 会重新安装(绝不出现「标记还在、补丁已摘」的假活状态)。
3350
+ * @returns {() => void} disposer。
3351
+ */
3352
+ export function ensureMobileResponseCompression() {
3353
+ if (activeMobileCompressionDispose === null) activeMobileCompressionDispose = installMobileResponseCompression()
3354
+ const inner = activeMobileCompressionDispose
3355
+ let released = false
3356
+ return () => {
3357
+ if (released || activeMobileCompressionDispose === null) return
3358
+ released = true
3359
+ activeMobileCompressionDispose = null
3360
+ inner()
3361
+ }
3362
+ }
3363
+
3364
+
3106
3365
  function apply(ctx) {
3107
3366
  const dshHome = resolveDshHome()
3108
3367
  let featureSettings = DEFAULT_FEATURE_SETTINGS
@@ -3398,6 +3657,27 @@ function apply(ctx) {
3398
3657
  for (const controller of quotaAbortControllers) controller.abort()
3399
3658
  quotaAbortControllers.clear()
3400
3659
  }, 'dsh-service quota upstream disposal')
3660
+ // ── 移动端适配(v0.30):大 JSON 响应压缩随功能开关热挂卸 ────────────────
3661
+ let mobileCompressionDispose = null
3662
+ const syncMobileCompression = () => {
3663
+ if (featureEnabled('mobileAdaptation')) {
3664
+ if (mobileCompressionDispose === null) mobileCompressionDispose = ensureMobileResponseCompression()
3665
+ } else if (mobileCompressionDispose !== null) {
3666
+ mobileCompressionDispose()
3667
+ mobileCompressionDispose = null
3668
+ }
3669
+ }
3670
+ ctx.effect(() => {
3671
+ featureSettingsListeners.add(syncMobileCompression)
3672
+ syncMobileCompression()
3673
+ return () => {
3674
+ featureSettingsListeners.delete(syncMobileCompression)
3675
+ if (mobileCompressionDispose !== null) {
3676
+ mobileCompressionDispose()
3677
+ mobileCompressionDispose = null
3678
+ }
3679
+ }
3680
+ }, 'dsh-service mobile response compression')
3401
3681
  const commands = ctx.get('commands')
3402
3682
  if (commands !== undefined) {
3403
3683
  ctx.effect(() => commands.register({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gehennawu/dsh-service",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "DSH Web 自托管运维面板:安全重启、健康监控、备份和 Linux 权限维护。",
5
5
  "type": "module",
6
6
  "scripts": {