@feiyang666/dsh-mobile-remote 1.9.0 → 2.0.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/lib/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  // dsh-mobile-remote — DSH 移动端远程控制插件(host 侧)。
2
2
  //
3
3
  // 两大职责:
4
- // 1. **移动端呈现层补丁**:向 Web UI index.html 注入移动端 CSS/JS
5
- // (composer 输入栏窄屏适配 + iOS 触控优化),不破坏 DSH 原生交互。
4
+ // 1. **远端可达性**:局域网旁挂听众(宿主只绑回环时)或直接复用宿主自身的
5
+ // 网卡监听(`--host <网卡IP>` 启动时),外加经中转服务器的 frp 外网隧道。
6
+ // 官方 dsh 0.2.2 起自带移动端适配,本插件**不再注入任何移动端 CSS/UI 补丁**,
7
+ // 只注入一个设备心跳脚本(设置页"已连接设备"统计需要)。
6
8
  // 2. **远程控制设置 API**(`/__dsh_remote/*`):
7
9
  // - `GET /__dsh_remote/status` 返回当前监听 host / port / LAN IP 列表 /
8
10
  // 远程控制开关状态 / 当前活跃设备数;
@@ -222,9 +224,6 @@ function httpFollow(startUrl, maxRedirects = 4) {
222
224
  })
223
225
  }
224
226
 
225
- /** 移动端断点:与 DSH 原生 SIDEBAR_AUTO_COLLAPSE = 1024 对齐。 */
226
- const MOBILE_MAX_WIDTH = 1024
227
-
228
227
  /** 心跳过期时间(毫秒):超过该时长未上报的设备视为离线。 */
229
228
  const HEARTBEAT_TTL = 90_000
230
229
 
@@ -259,7 +258,8 @@ export function apply(ctx) {
259
258
  try { ctx.logger?.warn?.(`[dsh-mobile-remote] ${message}${error ? `: ${String(error?.message || error)}` : ''}`) } catch { /* ignore */ }
260
259
  },
261
260
  })
262
- const remoteEnabledNow = () => lan.status().running
261
+ // 局域网可达 = 宿主自己绑在网卡上(--host)或旁挂听众在听
262
+ const remoteEnabledNow = () => bindInfo(ctx).direct || lan.status().running
263
263
 
264
264
  // 活跃设备表:deviceId -> 详细设备信息(见 handleHeartbeat)。
265
265
  const devices = new Map()
@@ -440,23 +440,33 @@ export function apply(ctx) {
440
440
  warn(`[dsh-mobile-remote] 旧配置迁移失败:${String(error?.message || error)}`)
441
441
  }
442
442
  }
443
- if (isLoopbackHost(ctx.webServer.host)) {
443
+ const bind = bindInfo(ctx)
444
+ if (bind.loopback) {
444
445
  // 旧绑定没生效(比如当前 profile 本来就没写过):直接清理,不影响运行中的监听
445
446
  migrate()
446
447
  } else {
447
- // 旧版本把 webserver 绑到了局域网 IP:现在改会触发一次重载把监听拉回回环,
448
- // 必须等 HMR watcher 就绪后再写,否则这一次写入不会触发重载。
448
+ // 两种可能:
449
+ // a) 旧版本把 webserver 绑到了局域网 IP —— 清理后会触发一次重载把监听拉回回环;
450
+ // 必须等 HMR watcher 就绪后再写,否则这一次写入不会触发重载。
451
+ // b) 宿主本身就是用 `--host <网卡IP>` 启动的(官方桌面端/第三方壳)——
452
+ // 这不是本插件写的配置,也不该去改;直连反而是最省事的形态,按 direct 处理。
449
453
  const timer = setTimeout(() => {
450
454
  migrate()
451
- warn('[dsh-mobile-remote] 监听已切回本机回环;局域网访问由插件的旁挂听众提供')
452
455
  }, 4000)
453
456
  migrationTimers.push(timer)
457
+ try {
458
+ ctx.logger?.info?.(
459
+ `[dsh-mobile-remote] dsh 直接监听 ${bind.host}:${bind.port}(--host),`
460
+ + '局域网/外网转发将以该地址为上游,不再需要同地址的旁挂听众',
461
+ )
462
+ } catch { /* ignore */ }
454
463
  }
455
464
  // 旧版本以「受管 patch 存在」表示开启;首次升级时迁移成持久化配置项
456
465
  if (getSetting('remoteEnabled', null) === null) {
457
466
  try { setSetting('remoteEnabled', isManagedPatchEnabled(ctx)) } catch { /* ignore */ }
458
467
  }
459
- if (getSetting('remoteEnabled', false) !== true) return
468
+ // direct 形态下即使配置项是 false,局域网也已经是可达的;仍按配置决定是否补其它网卡
469
+ if (getSetting('remoteEnabled', false) !== true && !bind.direct) return
460
470
  try {
461
471
  const result = await enableRemote(ctx, lan)
462
472
  if (!result.running) {
@@ -472,7 +482,7 @@ export function apply(ctx) {
472
482
  // 免去每次手动输入。设置 DSH_DISABLE_AUTO_RESTORE=1 可关闭该行为。
473
483
  if (process.env.DSH_DISABLE_AUTO_RESTORE !== '1') {
474
484
  const patchFile = patchFilePath(ctx)
475
- void autoRestoreExternal(external, patchFile, ctx.webServer.port || 3080)
485
+ void autoRestoreExternal(external, patchFile, ctx.webServer.port || 3080, bindInfo(ctx).upstream)
476
486
  }
477
487
 
478
488
  // ---- 端口自适应 ----
@@ -488,7 +498,7 @@ export function apply(ctx) {
488
498
  lastKnownPort = now
489
499
  // 局域网听众也要跟着端口走(否则手机地址指向一个没人监听的端口)
490
500
  if (lan.status().running) {
491
- void lan.start(getLanAddresses(), now).then((result) => {
501
+ void enableRemote(ctx, lan).then((result) => {
492
502
  if (result.running) {
493
503
  try { ctx.logger?.info?.(`[dsh-mobile-remote] 监听端口 ${from} → ${now},局域网听众已自适应重绑`) } catch { /* ignore */ }
494
504
  }
@@ -500,7 +510,7 @@ export function apply(ctx) {
500
510
  void startExternal(external, {
501
511
  bindCode: persisted.bindCode,
502
512
  serverBase: persisted.serverBase || undefined,
503
- }, patchFilePath(ctx), now).catch(() => { /* 状态由 startExternal 内部记录 */ })
513
+ }, patchFilePath(ctx), now, bindInfo(ctx).upstream).catch(() => { /* 状态由 startExternal 内部记录 */ })
504
514
  try {
505
515
  ctx.logger?.info?.(`[dsh-mobile-remote] 监听端口 ${from} → ${now},外网隧道已自适应重绑`)
506
516
  } catch { /* ignore */ }
@@ -571,6 +581,32 @@ function getLanAddresses() {
571
581
  return out.sort((a, b) => score(b) - score(a))
572
582
  }
573
583
 
584
+ /**
585
+ * 宿主真实监听信息(本插件所有「转发到哪里」的判断都以它为准)。
586
+ *
587
+ * dsh 0.2.x 的 `webserver.host` 只接受一块具体网卡的字面量地址,因此运行时可能有两种形态:
588
+ * - `loopback`:宿主只绑 127.0.0.1 → 手机必须靠旁挂 LAN 听众转发(本机窗口不受影响);
589
+ * - `direct` :宿主自己就绑在网卡上(官方桌面端/第三方壳用 `--host 192.168.x.x` 启动)。
590
+ * 此时该地址上已经有监听,旁挂听众再绑同一地址必然 EADDRINUSE,
591
+ * 而 127.0.0.1 上根本没有监听 —— 转发目标必须改成这块网卡本身。
592
+ * 这正是「局域网其实能访问、插件却显示未开启且没有二维码」与「外网域名 502」的共同根因。
593
+ * @param {import('@deepseek-ai/cordis').Context} [ctx]
594
+ * @returns {{host: string, port: number, loopback: boolean, direct: boolean, upstream: string}}
595
+ */
596
+ function bindInfo(ctx) {
597
+ const host = String(ctx?.webServer?.host || '127.0.0.1')
598
+ const port = Number(ctx?.webServer?.port) || 0
599
+ const loopback = isLoopbackHost(host)
600
+ return { host, port, loopback, direct: !loopback, upstream: loopback ? '127.0.0.1' : host }
601
+ }
602
+
603
+ /** 把 host:port 拼成 URL authority(IPv6 自动加方括号)。 */
604
+ function authorityOf(host, port) {
605
+ const h = String(host || '')
606
+ const wrapped = h.includes(':') && !h.startsWith('[') ? `[${h}]` : h
607
+ return port ? `${wrapped}:${port}` : wrapped
608
+ }
609
+
574
610
  /** 所有 IPv4 网卡详情(含网卡名 / 掩码 / MAC / CIDR / 是否回环),供设置页展示。 */
575
611
  function getLanInterfaces() {
576
612
  const out = []
@@ -1061,28 +1097,58 @@ async function queryServerStatus(base, bindCode) {
1061
1097
  * @param {ReturnType<typeof createLanProxy>} proxy
1062
1098
  */
1063
1099
  async function enableRemote(ctx, proxy) {
1064
- const port = ctx.webServer.port || 0
1100
+ const bind = bindInfo(ctx)
1101
+ const port = bind.port
1065
1102
  if (!port) throw new Error('dsh 尚未监听端口,请稍后重试')
1066
- const result = await proxy.start(getLanAddresses(), port)
1103
+ const lan = getLanAddresses()
1104
+ if (bind.direct) {
1105
+ // 宿主自己已经绑在这块网卡上:同地址不能再绑(EADDRINUSE),只补其余网卡。
1106
+ // 转发目标也必须是宿主真实监听的那块网卡 —— 回环上没有任何监听。
1107
+ const extras = lan.filter((ip) => ip !== bind.host)
1108
+ const result = extras.length
1109
+ ? await proxy.start(extras, port, { upstreamHost: bind.host, skipHosts: [bind.host] })
1110
+ : { running: false, hosts: [], port: 0, upstream: `${authorityOf(bind.host, port)}`, failures: [] }
1111
+ setSetting('remoteEnabled', true)
1112
+ return {
1113
+ // 直连由宿主提供,语义上就是「已开启」
1114
+ running: true,
1115
+ direct: true,
1116
+ hosts: [bind.host, ...result.hosts],
1117
+ port,
1118
+ upstream: result.upstream,
1119
+ sidecar: result.running === true,
1120
+ failures: result.failures || [],
1121
+ }
1122
+ }
1123
+ const result = await proxy.start(lan, port, { upstreamHost: bind.upstream })
1067
1124
  if (result.running) setSetting('remoteEnabled', true)
1068
1125
  return result
1069
1126
  }
1070
1127
 
1071
1128
  /**
1072
1129
  * 关闭局域网访问:释放旁挂听众,本机监听自始至终未受影响。
1130
+ * 注意:若宿主是用 `--host <网卡IP>` 启动的,那份监听属于 dsh 本身,插件无权关闭,
1131
+ * 只有改启动参数才能真正收回 —— 这里如实返回 direct 标记,由前端说明。
1073
1132
  * @param {ReturnType<typeof createLanProxy>} proxy
1133
+ * @param {import('@deepseek-ai/cordis').Context} [ctx]
1074
1134
  */
1075
- async function disableRemote(proxy) {
1135
+ async function disableRemote(proxy, ctx) {
1076
1136
  await proxy.stop()
1077
1137
  setSetting('remoteEnabled', false)
1138
+ return { direct: !!(ctx && bindInfo(ctx).direct) }
1078
1139
  }
1079
1140
 
1080
1141
  /** GET /__dsh_remote/status */
1081
1142
  function handleStatus(ctx, devices, external, tracker, dshCache, proxy) {
1082
1143
  return async (req, res) => {
1083
- const lan = getLanAddresses()
1144
+ const bind = bindInfo(ctx)
1145
+ // 宿主自己绑在网卡上时,这块地址排在最前(手机优先用直连地址)
1146
+ const lan = bind.direct
1147
+ ? [bind.host, ...getLanAddresses().filter((ip) => ip !== bind.host)]
1148
+ : getLanAddresses()
1084
1149
  const proxyState = proxy.status()
1085
- const remoteEnabled = proxyState.running
1150
+ // 「局域网可达」= 宿主直连(--host 网卡IP)或插件的旁挂听众至少有一个在听
1151
+ const remoteEnabled = bind.direct || proxyState.running
1086
1152
  // 外网 frpc 是否仍在运行
1087
1153
  const extRunning = !!external.proc && external.proc.exitCode === null
1088
1154
  // 手机访问地址统一附加新版 dsh 的浏览器会话 token(无认证时返回原 URL)
@@ -1135,27 +1201,36 @@ function handleStatus(ctx, devices, external, tracker, dshCache, proxy) {
1135
1201
  // WS 长连接是否已建立(供前端判断当前处于"实时推送"还是"退让/重连")
1136
1202
  const wsConnected = !!(external.wsClient && external.wsClient.isOpen())
1137
1203
  json(res, 200, {
1138
- host: ctx.webServer.host,
1139
- port: ctx.webServer.port,
1204
+ host: bind.host,
1205
+ port: bind.port,
1140
1206
  lanAddresses: lan,
1141
1207
  lanInterfaces: getLanInterfaces(),
1142
1208
  remoteEnabled,
1143
- // 本机监听地址(新版永远是回环:局域网访问由旁挂听众转发,不再改绑定)
1144
- bindHost: ctx.webServer.host,
1145
- // 旁挂听众的实时状态(开启后有 running/hosts/port)
1209
+ // 本机监听地址(可能回环,也可能是 --host 指定的网卡 IP)
1210
+ bindHost: bind.host,
1211
+ // 宿主是否自己就绑在网卡上(此时局域网直连由 dsh 提供,旁挂听众不再是必需)
1212
+ direct: {
1213
+ enabled: bind.direct,
1214
+ host: bind.host,
1215
+ port: bind.port,
1216
+ upstream: bind.upstream,
1217
+ },
1218
+ // 旁挂听众的实时状态(开启后有 running/hosts/port/upstream)
1146
1219
  lan: {
1147
1220
  running: proxyState.running,
1148
1221
  hosts: proxyState.hosts,
1149
1222
  port: proxyState.port,
1223
+ upstream: proxyState.upstream,
1224
+ direct: bind.direct,
1150
1225
  },
1151
1226
  patchEnabled: isManagedPatchEnabled(ctx),
1152
1227
  deviceCount: devices.size,
1153
1228
  devices: deviceList,
1154
1229
  patchFile: patchFilePath(ctx),
1155
1230
  profile: profileName(ctx),
1156
- url: lan.length ? authUrl(`http://${lan[0]}:${ctx.webServer.port}`) : null,
1231
+ url: lan.length ? authUrl(`http://${authorityOf(lan[0], bind.port)}`) : null,
1157
1232
  // 全部局域网 IP 的「手机可访问」地址(已带新版 dsh 认证 token;无认证时即裸地址)
1158
- lanUrls: lan.map((ip) => authUrl(`http://${ip}:${ctx.webServer.port}`)),
1233
+ lanUrls: lan.map((ip) => authUrl(`http://${authorityOf(ip, bind.port)}`)),
1159
1234
  // 本机运行时信息(诊断用,设置页展示更详细的数据)
1160
1235
  runtime: runtimeInfo(ctx),
1161
1236
  uptime: Math.floor(process.uptime()),
@@ -1180,6 +1255,10 @@ function handleStatus(ctx, devices, external, tracker, dshCache, proxy) {
1180
1255
  ? String(persisted.bindCode).slice(0, 4) + '****'
1181
1256
  : '',
1182
1257
  error: external.error || null,
1258
+ // frpc 转发的本地地址(宿主真实监听地址;非回环时是网卡 IP)
1259
+ localHost: external.localHost || null,
1260
+ // 外网入口可达性探测({ ok, status, at, error }):502 等会在这里直接暴露
1261
+ probe: external.probe || null,
1183
1262
  defaultServerBase: DEFAULT_SERVER_BASE,
1184
1263
  persisted: persisted
1185
1264
  ? { bindCode: persisted.bindCode || '', serverBase: persisted.serverBase || '' }
@@ -1233,18 +1312,24 @@ function handleToggle(ctx, proxy) {
1233
1312
  json(res, 200, {
1234
1313
  ok: true,
1235
1314
  enabled: true,
1315
+ direct: result.direct === true,
1236
1316
  lanHosts: result.hosts,
1237
- urls: result.hosts.map((ip) => `http://${ip}:${port}`),
1238
- note: 'lan listener started; dsh keeps listening on loopback (no restart, no patch)',
1317
+ urls: result.hosts.map((ip) => `http://${authorityOf(ip, port)}`),
1318
+ note: result.direct
1319
+ ? 'dsh is already bound to a LAN address (--host); the side-car listener is not needed'
1320
+ : 'lan listener started; dsh keeps listening on loopback (no restart, no patch)',
1239
1321
  })
1240
1322
  return
1241
1323
  }
1242
- await disableRemote(proxy)
1324
+ const stopped = await disableRemote(proxy, ctx)
1243
1325
  notifyChanged()
1244
1326
  json(res, 200, {
1245
1327
  ok: true,
1246
1328
  enabled: false,
1247
- note: 'lan listener stopped; local access was never affected',
1329
+ direct: stopped.direct === true,
1330
+ note: stopped.direct
1331
+ ? 'side-car listener stopped; dsh still listens on its --host address, remove that flag to fully close LAN access'
1332
+ : 'lan listener stopped; local access was never affected',
1248
1333
  })
1249
1334
  } catch (error) {
1250
1335
  json(res, 500, { ok: false, error: String(error?.message || error) })
@@ -1252,7 +1337,7 @@ function handleToggle(ctx, proxy) {
1252
1337
  }
1253
1338
  }
1254
1339
 
1255
- /** POST /__dsh_remote/heartbeat(移动端上报的详细设备元数据,见下方 MOBILE_JS)。 */
1340
+ /** POST /__dsh_remote/heartbeat(浏览器上报的详细设备元数据,见下方 CLIENT_JS)。 */
1256
1341
  function handleHeartbeat(devices, tracker) {
1257
1342
  return async (req, res) => {
1258
1343
  const body = await readBody(req)
@@ -1335,7 +1420,11 @@ function handleExternalStart(ctx, external) {
1335
1420
  try { body = await readBody(req) } catch { /* ignore */ }
1336
1421
  try {
1337
1422
  const patchFile = patchFilePath(ctx)
1338
- const result = await startExternal(external, body, patchFile, ctx.webServer.port || 3080)
1423
+ // 第 5 个参数是 frpc 的本地转发地址:宿主未绑回环时必须是网卡 IP,
1424
+ // 否则 frpc 会转发到没人监听的 127.0.0.1:port —— 外网域名直接 502。
1425
+ const result = await startExternal(
1426
+ external, body, patchFile, ctx.webServer.port || 3080, bindInfo(ctx).upstream,
1427
+ )
1339
1428
  notifyChanged() // 外网隧道启动:通知 SSE 订阅者
1340
1429
  json(res, 200, Object.assign({ ok: true }, result))
1341
1430
  } catch (error) {
@@ -1369,14 +1458,14 @@ function stopFrpc(external) {
1369
1458
  function injectIntoIndex(html, remoteEnabled) {
1370
1459
  const bootConfig =
1371
1460
  '<script id="dsh-mobile-remote-config">window.__DSH_MOBILE_REMOTE__ = ' +
1372
- JSON.stringify({ lanEnabled: remoteEnabled, mobileMaxWidth: MOBILE_MAX_WIDTH }) +
1461
+ JSON.stringify({ lanEnabled: remoteEnabled }) +
1373
1462
  ';</script>'
1374
1463
  const gateStyle = `<style id="dsh-mobile-remote-gate-css">${GATE_CSS}</style>`
1375
1464
  const gateScript = `<script id="dsh-mobile-remote-gate-js">${GATE_JS}</script>`
1376
- const style = `<style id="dsh-mobile-remote-css">${MOBILE_CSS}</style>`
1377
- const script = `<script id="dsh-mobile-remote-js">${MOBILE_JS}</script>`
1465
+ // 不再注入任何移动端布局/交互 CSS:官方 dsh 0.2.2 起自带窄屏抽屉等适配。
1466
+ const script = `<script id="dsh-mobile-remote-js">${CLIENT_JS}</script>`
1378
1467
  // 门禁脚本注入在 head 最前,确保在 DSH 界面渲染前就绪
1379
- const injection = bootConfig + gateStyle + gateScript + style + script
1468
+ const injection = bootConfig + gateStyle + gateScript + script
1380
1469
 
1381
1470
  if (html.includes('</head>')) {
1382
1471
  return html.replace('</head>', `${injection}\n</head>`)
@@ -1632,454 +1721,24 @@ const GATE_JS = `
1632
1721
  })();
1633
1722
  `
1634
1723
 
1635
- const MOBILE_CSS = `
1636
- /* === dsh-mobile-remote 移动端输入栏补丁 ===
1637
- * 作用范围:仅在移动设备(<1024px)下生效。DSH 原生已经在 <1024px 时把侧边栏
1638
- * 折叠为 rail 并提供原生汉堡按钮,本补丁不碰那一套,只修 composer。
1639
- *
1640
- * 选择器结构(packages/client/ui-conversation/src/client/skeleton/InputBar.tsx):
1641
- * [data-composer-card] ← composer 卡片根
1642
- * ├ [data-input-scroll] ← 文本输入滚动区
1643
- * └ div:last-child ← 工具行 .row
1644
- * ├ div:first-child ← .tools(+ 按钮、访问模式、安全模式 select)
1645
- * └ div:last-child ← .trailing(模型 select、上下文计量、发送按钮)
1646
- */
1647
-
1648
- html[data-dsh-mobile] {
1649
- -webkit-text-size-adjust: 100%;
1650
- text-size-adjust: 100%;
1651
- }
1652
-
1653
- html[data-dsh-mobile] body {
1654
- -webkit-tap-highlight-color: transparent;
1655
- touch-action: manipulation;
1656
- overscroll-behavior-y: contain;
1657
- }
1658
-
1659
- /* ----- 输入栏工具行:窄屏下允许换行,避免左右两侧的选择器互相挤压粘连 ----- */
1660
- html[data-dsh-mobile] [data-composer-card] > div:last-child {
1661
- flex-wrap: wrap;
1662
- row-gap: 6px;
1663
- }
1664
-
1665
- /* 左半区(+ 按钮 + 访问模式 + 安全模式)可收缩 */
1666
- html[data-dsh-mobile] [data-composer-card] > div:last-child > div:first-child {
1667
- flex: 1 1 auto;
1668
- min-width: 0;
1669
- }
1670
-
1671
- /* 右半区(模型选择 + 上下文 + 发送)换到第二行右对齐,与左半区彻底分开 */
1672
- html[data-dsh-mobile] [data-composer-card] > div:last-child > div:last-child {
1673
- flex: none !important;
1674
- width: 100% !important;
1675
- justify-content: flex-end !important;
1676
- }
1677
-
1678
- /* 工具行内的所有 <select>(选择模型 / 选择安全模式等)限宽防溢出 + 加大触控目标 */
1679
- html[data-dsh-mobile] [data-composer-card] select {
1680
- max-width: min(46vw, 200px) !important;
1681
- min-height: 36px;
1682
- font-size: 14px;
1683
- }
1684
-
1685
- /* 工具行内的按钮加大触控目标(44px 苹果 HIG 推荐) */
1686
- html[data-dsh-mobile] [data-composer-card] > div:last-child button {
1687
- min-width: 40px;
1688
- min-height: 40px;
1689
- }
1690
-
1691
- /* ----- iOS 触屏优化:输入框字号 >= 16px 防止聚焦自动缩放 ----- */
1692
- html[data-dsh-mobile] textarea,
1693
- html[data-dsh-mobile] input[type='text'],
1694
- html[data-dsh-mobile] input[type='search'] {
1695
- font-size: 16px !important;
1696
- }
1697
-
1698
- /* ----- 移动端 Tooltip 抑制 -----
1699
- * DSH 原生 Tooltip 气泡由 hover / focus 触发(<span role="tooltip">)。
1700
- * 触屏设备上没有 hover,点击按钮后焦点停留在按钮上,气泡会一直显示在屏幕上
1701
- * (点击空白 / 滚动也不会消失)——即“停止 / 开始 / 关闭菜单栏等按钮的提示文字
1702
- * 常驻屏幕”问题。移动端直接隐藏气泡,按钮仍保留 aria-label 无障碍名。
1703
- */
1704
- html[data-dsh-mobile] [role='tooltip'] {
1705
- display: none !important;
1706
- visibility: hidden !important;
1707
- opacity: 0 !important;
1708
- pointer-events: none !important;
1709
- }
1710
-
1711
- /* ----- 移动端设置页适配 -----
1712
- * 原生设置弹窗是 800px 双栏布局(188px 导航栏 + 内容列),手机上内容被挤得
1713
- * 几乎没有空间。由 MOBILE_JS 的 MutationObserver 把设置弹窗打上
1714
- * data-dsh-settings 标记(结构:role=dialog 且首个子元素为 <nav>),这里改为
1715
- * 全屏单栏:导航栏变顶部横向滚动标签,内容区独占剩余空间。
1716
- * 不依赖 harness 内部哈希类名,harness 升级后仍能直接适配。
1717
- */
1718
- html[data-dsh-mobile] [data-dsh-settings] {
1719
- display: flex !important;
1720
- flex-direction: column !important;
1721
- width: 100vw !important;
1722
- max-width: 100vw !important;
1723
- height: 100vh !important;
1724
- height: 100dvh !important;
1725
- max-height: 100vh !important;
1726
- max-height: 100dvh !important;
1727
- border-radius: 0 !important;
1728
- }
1729
-
1730
- /* 导航栏:横向标签条,可横向滚动 */
1731
- html[data-dsh-mobile] [data-dsh-settings] > nav {
1732
- display: flex !important;
1733
- flex-direction: row !important;
1734
- align-items: center !important;
1735
- width: 100% !important;
1736
- flex: none !important;
1737
- padding: 8px 10px !important;
1738
- gap: 6px !important;
1739
- overflow-x: auto !important;
1740
- -webkit-overflow-scrolling: touch;
1741
- border-bottom: 1px solid var(--dsw-alias-border-l2, rgba(127,127,127,.18)) !important;
1742
- }
1743
-
1744
- /* 导航标题(设置面板的标题文案)在移动端隐藏,省出空间给标签 */
1745
- html[data-dsh-mobile] [data-dsh-settings] > nav > div:first-child {
1746
- display: none !important;
1747
- }
1748
-
1749
- /* 导航列表:横向排列、宽度自适应内容 */
1750
- html[data-dsh-mobile] [data-dsh-settings] > nav > div + div {
1751
- display: flex !important;
1752
- flex-direction: row !important;
1753
- width: max-content !important;
1754
- gap: 6px !important;
1755
- }
1756
-
1757
- /* 导航项:胶囊标签,加大触控目标 */
1758
- html[data-dsh-mobile] [data-dsh-settings] nav button {
1759
- flex: none !important;
1760
- height: 36px !important;
1761
- padding: 0 14px !important;
1762
- gap: 6px !important;
1763
- border-radius: 999px !important;
1764
- white-space: nowrap !important;
1765
- }
1766
-
1767
- /* 内容列:占满剩余空间。
1768
- * min-height:0 是关键——flex 子项默认 min-height:auto,内容列无法收缩到
1769
- * 比自身内容更矮,导致内容撑高后被面板 overflow:hidden 裁掉、内部 .options
1770
- * 区永远拿不到受限高度而不能滚动。设为 0 后内容列可收缩,把高度让给
1771
- * .options(其自身 overflow-y:auto)去内部滚动。 */
1772
- html[data-dsh-mobile] [data-dsh-settings] > div {
1773
- width: 100% !important;
1774
- flex: 1 1 auto !important;
1775
- min-width: 0 !important;
1776
- min-height: 0 !important;
1777
- overflow: hidden !important;
1778
- }
1779
-
1780
- /* 顶部 header 的按钮(关闭等)加大触控目标 */
1781
- html[data-dsh-mobile] [data-dsh-settings] > div > div:first-child button {
1782
- min-width: 36px !important;
1783
- min-height: 36px !important;
1784
- }
1785
-
1786
- /* 内容区边距收紧,让 section 内容有更多可用宽度 */
1787
- html[data-dsh-mobile] [data-dsh-settings] > div > div:last-child {
1788
- padding: 0 12px 16px !important;
1789
- }
1790
-
1791
- /* ----- 移动端上传图片按钮(composer 工具行,+ 加号旁)-----
1792
- * 由 MOBILE_JS 注入到 [data-composer-card] 工具行。视觉上与 DSH 原生
1793
- * 加号按钮同款(28px 圆、selector 填充),保证窄屏下排在一起不突兀。
1794
- */
1795
- html[data-dsh-mobile] [data-composer-card] .dsh-mr-attach {
1796
- display: grid;
1797
- place-items: center;
1798
- flex: none;
1799
- width: 28px;
1800
- height: 28px;
1801
- margin: 0;
1802
- padding: 0;
1803
- border: none;
1804
- border-radius: 999px;
1805
- background: var(--dsw-specific-selector, #ececee);
1806
- color: var(--dsw-alias-label-primary, #1f1f1f);
1807
- cursor: pointer;
1808
- -webkit-tap-highlight-color: transparent;
1809
- transition: background-color 100ms ease;
1810
- }
1811
- html[data-dsh-mobile] [data-composer-card] .dsh-mr-attach:active {
1812
- background: var(--dsw-alias-interactive-bg-hover-solid, #e0e0e3);
1813
- }
1814
- html[data-dsh-mobile] [data-composer-card] .dsh-mr-attach:disabled {
1815
- opacity: 0.45;
1816
- cursor: default;
1817
- }
1818
-
1819
- /* 发送状态指示:composer 上方的「上传中 / 发送中」小浮层(含旋转 spinner)。
1820
- * 位置:absolute 相对 [data-composer-card](其 position:relative 且无
1821
- * overflow 裁剪),贴在卡片上沿外侧。 */
1822
- html[data-dsh-mobile] [data-composer-card] .dsh-mr-sending {
1823
- position: absolute;
1824
- left: 12px;
1825
- right: 12px;
1826
- bottom: calc(100% + 8px);
1827
- z-index: 30;
1828
- display: flex;
1829
- align-items: center;
1830
- gap: 8px;
1831
- padding: 8px 12px;
1832
- border-radius: 10px;
1833
- background: var(--dsw-alias-surface-solid, #ffffff);
1834
- border: 1px solid var(--dsw-alias-divider, rgba(0, 0, 0, 0.08));
1835
- box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
1836
- font-size: 13px;
1837
- line-height: 1.4;
1838
- color: var(--dsw-alias-text-tertiary, #666);
1839
- }
1840
- html[data-dsh-mobile] [data-composer-card] .dsh-mr-sending .dsh-mr-spinner {
1841
- width: 14px;
1842
- height: 14px;
1843
- flex: none;
1844
- border: 2px solid var(--dsw-alias-divider, rgba(0, 0, 0, 0.12));
1845
- border-top-color: currentColor;
1846
- border-radius: 50%;
1847
- animation: dsh-mr-spin 0.8s linear infinite;
1848
- }
1849
- @keyframes dsh-mr-spin {
1850
- to { transform: rotate(360deg); }
1851
- }
1852
-
1853
- /* ----- 移动端上传轻提示气泡(自备极简 Toast)----- */
1854
- #dsh-mr-toast {
1855
- position: fixed;
1856
- left: 50%;
1857
- bottom: 96px;
1858
- transform: translateX(-50%);
1859
- z-index: 2147483000;
1860
- max-width: min(86vw, 340px);
1861
- padding: 9px 16px;
1862
- border-radius: 10px;
1863
- font-size: 13px;
1864
- line-height: 1.5;
1865
- text-align: center;
1866
- color: var(--dsw-alias-label-primary-inverted, #fff);
1867
- background: var(--dsw-alias-button-contrast-fill, rgba(0, 0, 0, 0.82));
1868
- box-shadow: 0 6px 20px rgba(0, 0, 0, 0.28);
1869
- pointer-events: none;
1870
- opacity: 0;
1871
- animation: dsh-mr-toast-in 2.4s ease forwards;
1872
- }
1873
- @keyframes dsh-mr-toast-in {
1874
- 0% { opacity: 0; transform: translateX(-50%) translateY(8px); }
1875
- 8% { opacity: 1; transform: translateX(-50%) translateY(0); }
1876
- 86% { opacity: 1; }
1877
- 100% { opacity: 0; transform: translateX(-50%) translateY(-4px); }
1878
- }
1879
-
1880
- /* ----- 移动端图片缩略图 rail:删除按钮触控目标加大 -----
1881
- * DSH 原生 AttachmentRail 的删除按钮只有 18px,触屏上太小。
1882
- * 结构:rail([role=group]) > item(div) > button(缩略图) + button(删除)。
1883
- */
1884
- html[data-dsh-mobile] [data-composer-card] [role='group'] > div > button:last-child {
1885
- width: 24px !important;
1886
- height: 24px !important;
1887
- }
1888
-
1889
- /* ----- 移动端弹窗(权限选择 / 模型选择菜单)适配 -----
1890
- * DSH 的 Menu(PermissionSelect 权限选择)与 ModelSelect(模型选择)都是
1891
- * 绝对定位浮层且不含 viewport 水平钳制:
1892
- * - 权限菜单 .list:left:0 + min-width:218px / max-width:360px——锚点在
1893
- * 左区时菜单向右展开,容易超出屏幕右侧;
1894
- * - 模型菜单 .menu:right:0 + width:min(240px, calc(100vw-32px))——锚点
1895
- * 在右区时向左展开,容易向左偏/遮挡左侧工具区。
1896
- * 这里:
1897
- * - 权限菜单(无 aria-label)统一限制宽度不超屏;水平位置由 MOBILE_JS
1898
- * 在菜单出现时用 translateX 钳制到视口内(无 transform 动画,可安全平移);
1899
- * - 模型菜单(带 aria-label 的 [role=menu])在移动端改为「底部抽屉」
1900
- * (bottom sheet):贴底、宽度自适应屏幕、高度受限内部滚动,不再从
1901
- * 锚点向左侧/上方展开遮挡 composer 左侧的 + / 上传图片等按钮。
1902
- */
1903
- html[data-dsh-mobile] [data-composer-card] [role='menu'] {
1904
- max-width: calc(100vw - 16px) !important;
1905
- }
1906
-
1907
- /* 模型选择菜单(ModelSelect 的 .menu 带 aria-label,可据此与权限菜单区分;
1908
- * 不依赖 harness 内部哈希类名)——移动端底部抽屉样式 */
1909
- html[data-dsh-mobile] [data-composer-card] [role='menu'][aria-label] {
1910
- position: fixed !important;
1911
- top: auto !important;
1912
- left: 8px !important;
1913
- right: 8px !important;
1914
- bottom: 24px; /* fallback:不支持 max()/env() 的旧浏览器也保持上移 */
1915
- bottom: max(24px, env(safe-area-inset-bottom, 24px)) !important;
1916
- width: auto !important;
1917
- min-width: 0 !important;
1918
- max-width: none !important;
1919
- max-height: 62vh !important;
1920
- z-index: 1000 !important;
1921
- border-radius: 16px !important;
1922
- overflow: hidden !important;
1923
- overscroll-behavior: contain !important;
1924
- }
1925
-
1926
- /* ----- 移动端插件宽表兜底:防止被压成逐字竖排 -----
1927
- * dsh-usage-plugin 的「消耗表 / 消耗明细 / 每日统计」等 9–11 列宽表依赖其
1928
- * 自身在运行时注入的全局样式(.dsh-usage-table{min-width:720px} + ≤540px
1929
- * 折叠中间列)来避免列被压到极限宽度。若该样式因 CSP 或加载时序未生效,
1930
- * 手机窄屏下每列会被压到约 1 字符宽、内容逐字竖排、数据无法阅读。
1931
- * 本插件(dsh-mobile-remote)的样式是随 index.html 一起由服务端下发的,
1932
- * 必定生效,因此作为可靠兜底:
1933
- * - 宽表强制 min-width:720px,配合其外层 overflow-x 容器横向滑动,始终可读;
1934
- * - ≤540px 时对带 collapse-mobile 的宽表隐藏第 4 列至倒数第 2 列,只保留
1935
- * 前 3 个主标识列 + 最后合计列,手机上一屏即可看全关键数据;
1936
- * - 价格表等窄表(无 collapse-mobile)不折叠,保留横向滚动。
1937
- */
1938
- html[data-dsh-mobile] .dsh-usage-table {
1939
- min-width: 720px !important;
1940
- }
1941
- @media (max-width: 540px) {
1942
- html[data-dsh-mobile] .dsh-usage-table.collapse-mobile tr > *:not(:nth-child(-n+3)):not(:last-child) {
1943
- display: none !important;
1944
- }
1945
- html[data-dsh-mobile] .dsh-usage-table.collapse-mobile {
1946
- min-width: 320px !important;
1947
- }
1948
- }
1949
-
1950
- /* ================================================================== */
1951
- /* 移动端侧栏:默认彻底收起,悬浮按钮唤出「抽屉」 */
1952
1724
  /* ------------------------------------------------------------------ */
1953
- /* 原生在 <1024px 时会把侧栏折成 56px 图标 rail(IconPanelLeft 按钮), */
1954
- /* 这在 390px 宽的手机上仍吃掉 14% 的横向空间;而原生展开是「挤压中栏」的 */
1955
- /* 网格列(center 会被压到 ~110px),手机上基本不可用。 */
1956
- /* 因此这里改成: */
1957
- /* 1) 收起的常态下把侧栏轨道压到 0px(内容区吃满整屏); */
1958
- /* 2) 点悬浮按钮展开时,让侧栏脱离网格、变成覆盖式抽屉 + 半透明遮罩; */
1959
- /* 3) 抽屉的开合仍然驱动官方 store(点击原生 toggle 按钮),只是用 CSS */
1960
- /* 把「挤压」改写成「浮层」,因此原生交互、快捷键、aria 全部保留。 */
1961
- /* 框架节点用 data-dsh-mobile-frame 标记(由 MOBILE_JS 打上),避免依赖 */
1962
- /* harness 的哈希类名。 */
1963
- /* ================================================================== */
1964
-
1965
- /* 常态:侧栏轨道 0 宽,内容区独占整屏 */
1966
- html[data-dsh-mobile] [data-dsh-mobile-frame] {
1967
- grid-template-columns: 0px minmax(0px, 1fr) minmax(0px, 0px) !important;
1968
- }
1969
-
1970
- /* 抽屉打开:侧栏变成从左侧滑入的浮层 */
1971
- html[data-dsh-mobile][data-dsh-nav='open'] [data-dsh-mobile-frame] > div:first-child {
1972
- position: fixed !important;
1973
- left: 0 !important;
1974
- top: 0 !important;
1975
- bottom: 0 !important;
1976
- width: min(86vw, 330px) !important;
1977
- max-width: 86vw !important;
1978
- z-index: 9010 !important;
1979
- overflow: hidden !important;
1980
- border-right: 1px solid var(--dsw-alias-border-l2, rgba(127, 127, 127, .22)) !important;
1981
- border-radius: 0 16px 16px 0 !important;
1982
- box-shadow: 0 18px 60px rgba(0, 0, 0, .38) !important;
1983
- animation: dshMobileNavIn .2s cubic-bezier(.2, .8, .3, 1);
1984
- }
1985
-
1986
- /* 侧栏内部根节点:宽度铺满抽屉(原生在窄屏下用内联宽度 = rail 宽度) */
1987
- html[data-dsh-mobile][data-dsh-nav='open'] [data-dsh-mobile-frame] > div:first-child > * {
1988
- width: 100% !important;
1989
- max-width: none !important;
1990
- height: 100% !important;
1991
- }
1992
-
1993
- @keyframes dshMobileNavIn {
1994
- from { transform: translateX(-14px); opacity: .6; }
1995
- to { transform: translateX(0); opacity: 1; }
1996
- }
1997
-
1998
- /* 抽屉遮罩:点击任意空白处收起 */
1999
- #dsh-mobile-nav-scrim {
2000
- position: fixed;
2001
- inset: 0;
2002
- z-index: 9005;
2003
- background: rgba(0, 0, 0, .46);
2004
- opacity: 0;
2005
- pointer-events: none;
2006
- transition: opacity .18s ease;
2007
- -webkit-tap-highlight-color: transparent;
2008
- }
2009
-
2010
- html[data-dsh-mobile][data-dsh-nav='open'] #dsh-mobile-nav-scrim {
2011
- opacity: 1;
2012
- pointer-events: auto;
2013
- }
2014
-
2015
- /* 抽屉打开时锁住背景滚动(只锁 body,不动框架自身布局) */
2016
- html[data-dsh-mobile][data-dsh-nav='open'] body {
2017
- overflow: hidden !important;
2018
- overscroll-behavior: none;
2019
- }
2020
-
2021
- /* 悬浮唤出按钮(收起态唯一入口;抽屉打开时淡出) */
2022
- #dsh-mobile-nav-btn {
2023
- position: fixed;
2024
- top: calc(env(safe-area-inset-top, 0px) + 8px);
2025
- left: 8px;
2026
- width: 38px;
2027
- height: 38px;
2028
- display: flex;
2029
- align-items: center;
2030
- justify-content: center;
2031
- padding: 0;
2032
- border: 1px solid var(--dsw-alias-border-l2, rgba(127, 127, 127, .26));
2033
- border-radius: 12px;
2034
- background: var(--dsw-alias-bg-l1, rgba(255, 255, 255, .86));
2035
- color: var(--dsw-alias-text-l1, inherit);
2036
- box-shadow: 0 2px 12px rgba(0, 0, 0, .18);
2037
- -webkit-backdrop-filter: blur(10px);
2038
- backdrop-filter: blur(10px);
2039
- z-index: 9020;
2040
- cursor: pointer;
2041
- transition: transform .15s ease, opacity .15s ease;
2042
- }
2043
-
2044
- #dsh-mobile-nav-btn:active {
2045
- transform: scale(.94);
2046
- }
2047
-
2048
- html[data-dsh-mobile][data-dsh-nav='open'] #dsh-mobile-nav-btn {
2049
- opacity: 0;
2050
- pointer-events: none;
2051
- }
2052
-
2053
- /* 抽屉里没有可折叠的宿主时(极端情况)也不留白 */
2054
- html[data-dsh-mobile]:not([data-dsh-nav='open']) [data-dsh-mobile-frame] > div:first-child {
2055
- overflow: hidden !important;
2056
- }
2057
- `
2058
-
2059
- /* ------------------------------------------------------------------ */
2060
- /* 移动端设备检测 + 心跳上报:仅设置 html[data-dsh-mobile] 标记,并定时向 */
2061
- /* host 上报设备在线状态(供设置页“当前连接的设备数量”统计)。 */
2062
- /* 不注入任何 UI、不修改任何 React 状态、不动任何 DOM 节点。 */
1725
+ /* 设备心跳(本插件唯一注入的前端脚本) */
1726
+ /* */
1727
+ /* 官方 dsh 0.2.2 起已自带移动端适配:<768px 时侧栏改为浮层抽屉(带遮罩、 */
1728
+ /* inert 与 Esc 收起)、1024px 自动塌缩、移动 UA 特判(不抢焦点、提交后 */
1729
+ /* 收键盘)、iOS 软键盘光标修正与 PWA 清单。因此本插件不再注入任何布局 */
1730
+ /* CSS / 交互补丁(旧版的移动端 CSS、上传图片按钮、侧栏抽屉、Toast、 */
1731
+ /* 发送状态浮层、菜单钳制全部删除),只保留设备心跳上报——设置页的 */
1732
+ /* "当前连接的设备"列表依赖它。 */
1733
+ /* 本脚本不注入 UI、不改结构、不接管原生交互。 */
2063
1734
  /* ------------------------------------------------------------------ */
2064
1735
 
2065
- const MOBILE_JS = `
1736
+ const CLIENT_JS = `
2066
1737
  (function () {
2067
1738
  'use strict';
2068
1739
 
2069
- var cfg = window.__DSH_MOBILE_REMOTE__ || { lanEnabled: false, mobileMaxWidth: 1024 };
2070
- var MAX_WIDTH = cfg.mobileMaxWidth || 1024;
2071
-
2072
- /* 设备检测:移动 UA,或触屏 + 窄视口。与 DSH 原生 SIDEBAR_AUTO_COLLAPSE 同界。 */
2073
- function isMobile() {
2074
- var ua = navigator.userAgent || '';
2075
- var mobileUA = /Android|iPhone|iPad|iPod|webOS|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(ua);
2076
- var touch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
2077
- var narrow = Math.min(window.innerWidth, (window.screen && window.screen.width) || Infinity) <= MAX_WIDTH;
2078
- return mobileUA || (touch && narrow);
2079
- }
2080
-
2081
- // 设备心跳:稳定标识 + 30s 间隔上报,host 统计并展示"已连接设备"。
2082
- // 所有访问本 DSH 的浏览器(PC + 手机)都上报,仅移动端才打 data-dsh-mobile 标记。
1740
+ /* 设备心跳:稳定标识 + 30s 间隔上报,host 统计并展示"已连接设备"。
1741
+ * PC 与手机都会上报(设备列表按 UA 展示类型),不区分是否移动端。 */
2083
1742
  try {
2084
1743
  var KEY = '__dsh_mobile_remote_device';
2085
1744
  var id = localStorage.getItem(KEY);
@@ -2087,8 +1746,7 @@ const MOBILE_JS = `
2087
1746
  id = 'dev-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
2088
1747
  localStorage.setItem(KEY, id);
2089
1748
  }
2090
- // 采集当前设备元数据,随心跳上报,host 在设置页展示详细设备信息。
2091
- // 电池信息为异步获取,首次拿到后缓存在变量中,后续心跳一并上报。
1749
+ // 电池信息异步获取,首次拿到后缓存在变量中,后续心跳一并上报。
2092
1750
  var batteryLevel = null;
2093
1751
  var batteryCharging = null;
2094
1752
  try {
@@ -2099,6 +1757,10 @@ const MOBILE_JS = `
2099
1757
  }).catch(function () {});
2100
1758
  }
2101
1759
  } catch (e) {}
1760
+ function isMobile() {
1761
+ var ua = navigator.userAgent || '';
1762
+ return /Android|iPhone|iPad|iPod|Mobile/i.test(ua);
1763
+ }
2102
1764
  function deviceMeta() {
2103
1765
  var scr = window.screen || {};
2104
1766
  var scrStr = (scr.width && scr.height)
@@ -2144,483 +1806,8 @@ const MOBILE_JS = `
2144
1806
  }
2145
1807
  beat();
2146
1808
  setInterval(beat, 30000);
1809
+ window.__DSH_REMOTE_READY__ = { mobile: isMobile(), at: Date.now() };
2147
1810
  } catch (e) {}
2148
-
2149
- if (!isMobile()) return;
2150
-
2151
- document.documentElement.setAttribute('data-dsh-mobile', '');
2152
-
2153
- // 标记设置弹窗:原生设置页 = role="dialog" 且首个子元素为 <nav>(双栏弹窗)。
2154
- // 打上 data-dsh-settings 标记后,上面的移动端 CSS 可精确适配,无需依赖
2155
- // harness 内部的哈希类名——harness 升级后只要弹窗结构仍是
2156
- // “dialog 直下 nav + 内容列”,本插件无需修改即可直接适配。
2157
- function tagSettingsPanel() {
2158
- var dialogs = document.querySelectorAll('[role="dialog"][aria-modal="true"]');
2159
- for (var i = 0; i < dialogs.length; i++) {
2160
- var dlg = dialogs[i];
2161
- if (dlg.getAttribute('data-dsh-settings') === '1') continue;
2162
- var kids = dlg.children;
2163
- var hasNav = false;
2164
- for (var j = 0; j < kids.length; j++) {
2165
- if (kids[j].tagName === 'NAV') { hasNav = true; break; }
2166
- }
2167
- if (hasNav) dlg.setAttribute('data-dsh-settings', '1');
2168
- }
2169
- }
2170
- tagSettingsPanel();
2171
- if (typeof MutationObserver !== 'undefined') {
2172
- // 设置弹窗是动态挂载/卸载的,用 MutationObserver 监听其出现并打标。
2173
- var obs = new MutationObserver(function () { tagSettingsPanel(); });
2174
- try { obs.observe(document.documentElement, { childList: true, subtree: true }); } catch (e) {}
2175
- window.__DSH_MOBILE_REMOTE_SETTINGS_OBSERVER__ = obs;
2176
- }
2177
-
2178
- // 调试钩子:在控制台暴露状态,便于移动端远程排查。
2179
- window.__DSH_MOBILE_REMOTE_READY__ = {
2180
- mobile: true,
2181
- viewport: { w: window.innerWidth, h: window.innerHeight },
2182
- dpr: window.devicePixelRatio || 1,
2183
- lanEnabled: cfg.lanEnabled === true,
2184
- };
2185
-
2186
- /* ==================================================================
2187
- * 移动端上传图片(composer 加号旁的「上传图片」入口)
2188
- * ----------------------------------------------------------------
2189
- * DSH 桌面端原生支持图片消息(粘贴 / 拖放均可,格式/数量/大小预检查、
2190
- * 缩略图 rail、随消息上传全部由官方链路完成),但移动端没有「选择图片」
2191
- * 的入口:工具行的 + 按钮只打开命令菜单。
2192
- *
2193
- * 此处注入一个图片按钮 + 隐藏的 <input type=file>。用户在手机上通过
2194
- * 系统相册 / 文件选择器选图后,用普通对象模拟 clipboardData / dataTransfer
2195
- * (不依赖 new DataTransfer(),iOS Safari 兼容性差)派发 paste 事件——
2196
- * DSH 的 onPaste 原生 intakeImages 自动接管;若 paste 未生效,再回退向
2197
- * document 派发 drop 事件(DSH 的文档级 onDrop 同样走 intakeImages)。
2198
- * 全程不依赖 DSH 内部 API,harness 升级后只要 paste / drop 链路还在即可
2199
- * 正常工作。
2200
- * ================================================================== */
2201
-
2202
- // 轻提示:DSH 的 Toast 是内部组件,这里自备一个极简气泡。
2203
- var mrToastTimer = null;
2204
- function mrToast(text) {
2205
- var old = document.getElementById('dsh-mr-toast');
2206
- if (old) { try { old.parentNode.removeChild(old); } catch (e) {} }
2207
- var t = document.createElement('div');
2208
- t.id = 'dsh-mr-toast';
2209
- t.textContent = text;
2210
- (document.body || document.documentElement).appendChild(t);
2211
- if (mrToastTimer) clearTimeout(mrToastTimer);
2212
- mrToastTimer = setTimeout(function () {
2213
- try { t.parentNode && t.parentNode.removeChild(t); } catch (e) {}
2214
- }, 2400);
2215
- }
2216
-
2217
- // 隐藏的文件选择器(全局唯一,选完即重置,可重复选同一张图)。
2218
- // 注意:本脚本注入在 <head>,此时 document.body 可能尚未创建;若直接
2219
- // 挂到 documentElement,部分 iOS WebView 会拒绝 .click() 打开选择器,
2220
- // 因此等 body 就绪后再挂载(change 监听与挂载时机无关)。
2221
- var mrFileInput = document.createElement('input');
2222
- mrFileInput.type = 'file';
2223
- mrFileInput.accept = 'image/*';
2224
- mrFileInput.multiple = true;
2225
- mrFileInput.tabIndex = -1;
2226
- mrFileInput.setAttribute('aria-hidden', 'true');
2227
- mrFileInput.style.cssText = 'position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0;pointer-events:none;';
2228
- mrFileInput.addEventListener('change', function () {
2229
- var files = [];
2230
- try { files = Array.prototype.slice.call(mrFileInput.files || []); } catch (e) {}
2231
- if (files.length > 0) mrIntakeFiles(files);
2232
- try { mrFileInput.value = ''; } catch (e) {}
2233
- });
2234
- function mrMountFileInput() {
2235
- var host = document.body || document.documentElement;
2236
- if (host && !mrFileInput.parentNode) host.appendChild(mrFileInput);
2237
- }
2238
- if (document.body) mrMountFileInput();
2239
- else document.addEventListener('DOMContentLoaded', mrMountFileInput, { once: true });
2240
-
2241
- // ===== 图片 intake:兼容各种浏览器的事件派发 =====
2242
- // DSH 原生 intake 读两个事件:
2243
- // - onPaste(React 合成事件):e.clipboardData.items —— 只要求 items
2244
- // 可迭代、每项 kind==='file' 且 getAsFile() 返回真实 File;
2245
- // - onDrop(document 原生监听器):e.dataTransfer.types / .files。
2246
- // 我们不构造 DataTransfer / ClipboardEvent(iOS Safari 上
2247
- // new DataTransfer() + items.add() 之后 files 返回空 FileList、
2248
- // item.getAsFile() 可能返回 null,兼容性差),而是用普通 JS 对象
2249
- // 模拟这两个接口——任何浏览器都可靠。结果用「缩略图是否真的出现」
2250
- // 判断(不依赖 defaultPrevented——locked/machineBusy 时 DSH 也会
2251
- // preventDefault)。
2252
-
2253
- // 当前 composer 附件缩略图数量(rail 渲染后计数)。
2254
- function mrCountRailImages() {
2255
- try { return document.querySelectorAll('[data-composer-card] [role="group"] img').length; } catch (e) { return 0; }
2256
- }
2257
-
2258
- // 构造模拟 clipboardData(供 onPaste 使用):items 是普通数组,每项
2259
- // 的 kind='file'、getAsFile() 返回真实 File;getData/setData/clearData
2260
- // 补齐 DSH onPaste 会调用的接口(缺 getData 会抛 TypeError 导致 React
2261
- // 事件处理器崩溃)。绕开 DataTransfer 构造器在 iOS Safari 上的 files
2262
- // 为空 / getAsFile 返回 null 等兼容问题。
2263
- function mrBuildClipboardData(images) {
2264
- var items = [];
2265
- for (var j = 0; j < images.length; j++) {
2266
- (function (file) {
2267
- items.push({
2268
- kind: 'file',
2269
- type: file.type || '',
2270
- getAsFile: function () { return file; },
2271
- });
2272
- })(images[j]);
2273
- }
2274
- return {
2275
- items: items,
2276
- files: images,
2277
- types: ['Files'],
2278
- getData: function () { return ''; },
2279
- setData: function () {},
2280
- clearData: function () {},
2281
- };
2282
- }
2283
-
2284
- // 构造模拟 dataTransfer(供 onDrop 使用):只读 .types / .files。
2285
- function mrBuildDataTransfer(images) {
2286
- return { files: images, types: ['Files'] };
2287
- }
2288
-
2289
- // 向 document 派发 drop(DSH 的原生 onDrop 监听器会 intakeImages)。
2290
- function mrDispatchDrop(dt) {
2291
- try {
2292
- var evt = new Event('drop', { bubbles: true, cancelable: true });
2293
- try { evt.dataTransfer = dt; } catch (e) {}
2294
- document.dispatchEvent(evt);
2295
- return true;
2296
- } catch (e) { return false; }
2297
- }
2298
-
2299
- // 向 textarea 派发 paste(React 合成 onPaste 会 intakeImages)。
2300
- function mrDispatchPaste(cd, textarea) {
2301
- try {
2302
- var evt = new Event('paste', { bubbles: true, cancelable: true });
2303
- try { evt.clipboardData = cd; } catch (e) {}
2304
- textarea.dispatchEvent(evt);
2305
- return true;
2306
- } catch (e) { return false; }
2307
- }
2308
-
2309
- // 把选中的图片喂给 DSH 原生 intake。
2310
- function mrIntakeFiles(files) {
2311
- var textarea = document.querySelector('[data-composer-card] [data-input-scroll] textarea');
2312
- if (!textarea) { mrToast('未找到输入框,请重试'); return; }
2313
- if (textarea.disabled || textarea.readOnly) { mrToast('当前输入框不可用'); return; }
2314
- var images = [];
2315
- for (var i = 0; i < files.length; i++) {
2316
- // 注意:本函数经模板字符串注入 <script>,正则里的反斜杠转义(\/)
2317
- // 会被当作非转义序列丢弃(/^image\//i 变成 /^image//i,解析成除法,
2318
- // 运行时抛 i.test is not a function)。因此这里用 indexOf 判断类型。
2319
- if ((files[i].type || '').indexOf('image/') === 0) images.push(files[i]);
2320
- }
2321
- if (images.length === 0) { mrToast('请选择图片文件'); return; }
2322
- var before = mrCountRailImages();
2323
- // 方式一:paste(React onPaste 读 clipboardData.items,模拟对象对
2324
- // iOS Safari / 安卓 / 桌面全兼容)。
2325
- mrDispatchPaste(mrBuildClipboardData(images), textarea);
2326
- setTimeout(function () {
2327
- if (mrCountRailImages() > before) return;
2328
- // 方式二:drop 到 document(原生监听器兜底)。
2329
- mrDispatchDrop(mrBuildDataTransfer(images));
2330
- setTimeout(function () {
2331
- if (mrCountRailImages() > before) return;
2332
- mrToast('图片未添加上,请长按输入框粘贴图片');
2333
- }, 300);
2334
- }, 300);
2335
- }
2336
-
2337
- // 图片按钮:与 DSH 原生 + 按钮同风格的小圆钮(SVG 图片图标)。
2338
- function mrMakeAttachBtn() {
2339
- var btn = document.createElement('button');
2340
- btn.type = 'button';
2341
- btn.className = 'dsh-mr-attach';
2342
- btn.setAttribute('aria-label', '上传图片');
2343
- btn.title = '上传图片';
2344
- btn.innerHTML =
2345
- '<svg viewBox="0 0 16 16" width="15" height="15" fill="none" aria-hidden="true">' +
2346
- '<rect x="1.6" y="2.7" width="12.8" height="10.6" rx="2.3" stroke="currentColor" stroke-width="1.35"/>' +
2347
- '<circle cx="5.4" cy="6.1" r="1.3" fill="currentColor"/>' +
2348
- '<path d="M2.4 12.4L6.3 8.3a1.05 1.05 0 0 1 1.5 0l1.15 1.2 2.2-2.35a1.05 1.05 0 0 1 1.55 0l1.9 2.1" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"/>' +
2349
- '</svg>';
2350
- btn.addEventListener('click', function (e) {
2351
- e.preventDefault();
2352
- if (btn.disabled) return;
2353
- try { mrFileInput.value = ''; } catch (err) {}
2354
- mrFileInput.click();
2355
- });
2356
- return btn;
2357
- }
2358
-
2359
- // 同步按钮禁用态:跟随 composer 的 textarea(session 移除 / 锁定 / 提交中)。
2360
- function mrSyncAttachState(btn) {
2361
- if (!btn) return;
2362
- var textarea = document.querySelector('[data-composer-card] [data-input-scroll] textarea');
2363
- btn.disabled = !textarea || textarea.disabled || textarea.readOnly;
2364
- }
2365
-
2366
- // 把上传按钮插到工具行(+ 加号按钮之后)。
2367
- function mrInjectAttach() {
2368
- var card = document.querySelector('[data-composer-card]');
2369
- if (!card || card.querySelector('.dsh-mr-attach')) return;
2370
- var tools = card.querySelector(':scope > div:last-child > div:first-child');
2371
- if (!tools) return;
2372
- var btn = mrMakeAttachBtn();
2373
- tools.insertBefore(btn, tools.children[1] || null);
2374
- mrSyncAttachState(btn);
2375
- }
2376
-
2377
- // ===== 移动端弹窗水平钳制 =====
2378
- // 权限选择菜单是绝对定位浮层,不含 viewport 钳制,窄屏下容易向右溢出
2379
- // 或向左偏(模型选择菜单在移动端已改为底部抽屉,由 CSS 定位并自带
2380
- // 钳制,见 mrClampMenus 内的 fixed 跳过)。菜单出现/布局变化时读取
2381
- // rect,用 translateX 把它平移回视口内(菜单无 transform 动画,平移
2382
- // 安全;每次覆盖式设置,不会累积偏移)。隐藏中的菜单 rect 为 0,跳过。
2383
- function mrClampMenus() {
2384
- var menus = document.querySelectorAll('[data-composer-card] [role="menu"]');
2385
- for (var i = 0; i < menus.length; i++) {
2386
- var menu = menus[i];
2387
- try {
2388
- // 模型选择菜单在移动端已改为固定底部抽屉(bottom sheet),
2389
- // 位置由 CSS 决定且自带视口钳制,跳过平移。
2390
- if (window.getComputedStyle(menu).position === 'fixed') continue;
2391
- var r = menu.getBoundingClientRect();
2392
- if (r.width === 0 && r.height === 0) continue;
2393
- var vw = window.innerWidth;
2394
- var dx = 0;
2395
- if (r.left < 8) dx = 8 - r.left;
2396
- else if (r.right > vw - 8) dx = (vw - 8) - r.right;
2397
- if (dx !== 0) menu.style.transform = 'translateX(' + dx + 'px)';
2398
- } catch (e) { /* ignore */ }
2399
- }
2400
- }
2401
-
2402
- // ===== 发送状态指示 =====
2403
- // DSH 点击发送后,machine 进入 'submitting'(消息)/ 'adjudicating'(命令
2404
- // 裁决)phase:发送前要先把附件图片 serializeImages 成 base64 再 prompt,
2405
- // 移动端大图这一步可能持续数秒,而会话列表要等发送成功才出现新消息,
2406
- // 用户容易误以为「没发出去」而重发或离开。这里在 composer 上方显示
2407
- // 「上传中 / 发送中」提示,phase 恢复后自动隐藏。只读 textarea 的
2408
- // data-phase,不依赖 DSH 内部 API。
2409
- var mrSendingEl = null;
2410
-
2411
- function mrMakeSendingEl() {
2412
- var el = document.createElement('div');
2413
- el.className = 'dsh-mr-sending';
2414
- el.setAttribute('role', 'status');
2415
- el.setAttribute('aria-live', 'polite');
2416
- return el;
2417
- }
2418
-
2419
- function mrShowSending() {
2420
- var card = document.querySelector('[data-composer-card]');
2421
- if (!card) return;
2422
- // 会话切换时 composer 卡片会被重建,旧浮层节点会脱离 DOM,需重建挂载。
2423
- if (!mrSendingEl || !mrSendingEl.isConnected) {
2424
- mrSendingEl = mrMakeSendingEl();
2425
- card.appendChild(mrSendingEl);
2426
- }
2427
- var hasImages = mrCountRailImages() > 0;
2428
- mrSendingEl.innerHTML = '<span class="dsh-mr-spinner"></span>' +
2429
- (hasImages ? '正在上传图片并发送…' : '正在发送…');
2430
- mrSendingEl.style.display = '';
2431
- }
2432
-
2433
- function mrHideSending() {
2434
- if (mrSendingEl) mrSendingEl.style.display = 'none';
2435
- }
2436
-
2437
- function mrSyncSending() {
2438
- var textarea = document.querySelector('[data-composer-card] [data-input-scroll] textarea');
2439
- if (!textarea) { mrHideSending(); return; }
2440
- var phase = textarea.getAttribute('data-phase') || '';
2441
- if (phase === 'submitting' || phase === 'adjudicating') mrShowSending();
2442
- else mrHideSending();
2443
- }
2444
-
2445
- function mrScan() {
2446
- mrInjectAttach();
2447
- var btn = document.querySelector('[data-composer-card] .dsh-mr-attach');
2448
- mrSyncAttachState(btn);
2449
- mrClampMenus();
2450
- mrSyncSending();
2451
- }
2452
-
2453
- // composer 是动态挂载/重建的(会话切换),用 MutationObserver 跟踪挂载
2454
- // 与 textarea 锁定态变化;requestAnimationFrame 合并高频回调。
2455
- mrScan();
2456
- var mrScanPending = false;
2457
- if (typeof MutationObserver !== 'undefined') {
2458
- var mrObs = new MutationObserver(function () {
2459
- if (mrScanPending) return;
2460
- mrScanPending = true;
2461
- requestAnimationFrame(function () {
2462
- mrScanPending = false;
2463
- mrScan();
2464
- });
2465
- });
2466
- try {
2467
- mrObs.observe(document.documentElement, {
2468
- childList: true,
2469
- subtree: true,
2470
- attributes: true,
2471
- attributeFilter: ['disabled', 'readonly', 'data-phase'],
2472
- });
2473
- } catch (e) {}
2474
- }
2475
-
2476
- /* ------------------------------------------------------------------ */
2477
- /* 移动端侧栏:默认收起,悬浮按钮唤出覆盖式抽屉 */
2478
- /* ------------------------------------------------------------------ */
2479
- /* 原生在窄屏把侧栏折成 56px 的 icon rail,展开又是「挤压中栏」的网格列—— */
2480
- /* 手机上一展开内容区只剩百来像素。这里保留官方 store 与原生 toggle 按钮, */
2481
- /* 只用 CSS 把「挤压」改写成「浮层抽屉」: */
2482
- /* · 常态:侧栏轨道 0px,内容吃满整屏(由 MOBILE_CSS 负责); */
2483
- /* · 点悬浮按钮 → 触发原生 toggle(store 真正展开)→ 打上 data-dsh-nav */
2484
- /* · 点遮罩 / 按 Esc / 选中会话 → 触发原生 toggle 收起并去掉标记 */
2485
- function mrNavInit() {
2486
- if (!isMobile()) return;
2487
- if (document.getElementById('dsh-mobile-nav-btn')) return;
2488
-
2489
- var html = document.documentElement;
2490
- var SIDEBAR_LABELS = ['打开侧边栏', 'Open sidebar', '展开侧边栏'];
2491
- var COLLAPSE_LABELS = ['收起侧边栏', 'Collapse sidebar'];
2492
-
2493
- function findFrame() {
2494
- var overlay = document.querySelector('[data-shell-overlay]');
2495
- if (overlay && overlay.parentElement) return overlay.parentElement;
2496
- var root = document.getElementById('root');
2497
- return root ? root.firstElementChild : null;
2498
- }
2499
-
2500
- function markFrame() {
2501
- var frame = findFrame();
2502
- if (!frame) return null;
2503
- if (!frame.hasAttribute('data-dsh-mobile-frame')) frame.setAttribute('data-dsh-mobile-frame', '');
2504
- return frame;
2505
- }
2506
-
2507
- function sidebarCollapsed() {
2508
- var frame = findFrame();
2509
- return !!(frame && frame.hasAttribute('data-sidebar-collapsed'));
2510
- }
2511
-
2512
- function findToggle(wanted) {
2513
- var buttons = document.querySelectorAll('button[aria-label]');
2514
- for (var i = 0; i < buttons.length; i++) {
2515
- var label = buttons[i].getAttribute('aria-label') || '';
2516
- for (var j = 0; j < wanted.length; j++) {
2517
- if (label === wanted[j]) return buttons[i];
2518
- }
2519
- }
2520
- return null;
2521
- }
2522
-
2523
- /* 点原生 toggle:不自造状态,保证官方 store / aria / 快捷键始终一致 */
2524
- function clickNativeToggle(expand) {
2525
- var btn = findToggle(expand ? SIDEBAR_LABELS : COLLAPSE_LABELS);
2526
- if (!btn) {
2527
- // 兜底:带侧栏快捷键标注的按钮(命令 id: sidebar.left.toggle)
2528
- var alt = document.querySelector('button[aria-keyshortcuts]');
2529
- var all = document.querySelectorAll('button[aria-keyshortcuts]');
2530
- for (var k = 0; k < all.length; k++) {
2531
- var ks = (all[k].getAttribute('aria-keyshortcuts') || '').toLowerCase();
2532
- if (ks.indexOf('b') !== -1) { btn = all[k]; break; }
2533
- }
2534
- if (!btn) btn = alt;
2535
- }
2536
- if (!btn) return false;
2537
- try { btn.click(); return true } catch (e) { return false }
2538
- }
2539
-
2540
- function openNav() {
2541
- markFrame();
2542
- if (sidebarCollapsed()) clickNativeToggle(true);
2543
- html.setAttribute('data-dsh-nav', 'open');
2544
- // 原生展开后会重新排版,下一帧再确认一次框架标记仍然有效
2545
- requestAnimationFrame(markFrame);
2546
- }
2547
-
2548
- function closeNav() {
2549
- // 先撤标记:避免原生收起动画期间出现「挤压态」的中间画面
2550
- html.removeAttribute('data-dsh-nav');
2551
- if (!sidebarCollapsed()) clickNativeToggle(false);
2552
- }
2553
-
2554
- var btn = document.createElement('button');
2555
- btn.id = 'dsh-mobile-nav-btn';
2556
- btn.type = 'button';
2557
- btn.setAttribute('aria-label', '打开侧边栏');
2558
- btn.innerHTML =
2559
- '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
2560
- 'stroke-width="2" stroke-linecap="round" aria-hidden="true">' +
2561
- '<path d="M4 7h16M4 12h16M4 17h16"/></svg>';
2562
- btn.addEventListener('click', function (event) {
2563
- event.preventDefault();
2564
- event.stopPropagation();
2565
- if (html.getAttribute('data-dsh-nav') === 'open') closeNav();
2566
- else openNav();
2567
- });
2568
-
2569
- var scrim = document.createElement('div');
2570
- scrim.id = 'dsh-mobile-nav-scrim';
2571
- scrim.setAttribute('aria-hidden', 'true');
2572
- scrim.addEventListener('click', function (event) {
2573
- event.preventDefault();
2574
- closeNav();
2575
- });
2576
-
2577
- document.body.appendChild(scrim);
2578
- document.body.appendChild(btn);
2579
-
2580
- // Esc 关闭(外接键盘 / 平板)
2581
- document.addEventListener('keydown', function (event) {
2582
- if (event.key === 'Escape' && html.getAttribute('data-dsh-nav') === 'open') closeNav();
2583
- });
2584
-
2585
- // 抽屉里点了会话 / 工作区等可导航项就自动收起
2586
- document.addEventListener('click', function (event) {
2587
- if (html.getAttribute('data-dsh-nav') !== 'open') return;
2588
- var frame = findFrame();
2589
- var col = frame && frame.firstElementChild;
2590
- if (!col || !col.contains(event.target)) return;
2591
- if (event.target.closest && event.target.closest('button[aria-label]')) {
2592
- var label = event.target.closest('button[aria-label]').getAttribute('aria-label');
2593
- if (COLLAPSE_LABELS.indexOf(label) !== -1) { closeNav(); return }
2594
- }
2595
- var item = event.target.closest && event.target.closest('a,[role="option"],[role="treeitem"],[role="menuitem"]');
2596
- if (item) setTimeout(closeNav, 60);
2597
- }, true);
2598
-
2599
- // 原生 toggle(含快捷键 Alt+B)改变了折叠态时,保持我们的标记同步
2600
- var frameWatch = null;
2601
- function watchFrame() {
2602
- var frame = markFrame();
2603
- if (!frame) return false;
2604
- if (frameWatch || typeof MutationObserver === 'undefined') return true;
2605
- frameWatch = new MutationObserver(function () {
2606
- var open = html.getAttribute('data-dsh-nav') === 'open';
2607
- var collapsed = frame.hasAttribute('data-sidebar-collapsed');
2608
- if (open && collapsed) html.removeAttribute('data-dsh-nav');
2609
- });
2610
- try { frameWatch.observe(frame, { attributes: true, attributeFilter: ['data-sidebar-collapsed'] }) } catch (e) {}
2611
- return true;
2612
- }
2613
-
2614
- // 框架节点可能晚于本脚本出现:短暂重试 + 兜底轮询
2615
- var tries = 0;
2616
- (function tick() {
2617
- if (watchFrame()) return;
2618
- if (tries++ > 40) return;
2619
- setTimeout(tick, 250);
2620
- })();
2621
- }
2622
-
2623
- try { mrNavInit(); } catch (e) {}
2624
1811
  })();
2625
1812
  `
2626
1813