dsh-remote-plugin 0.6.10 → 0.6.12

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
@@ -8,12 +8,15 @@ The official DSH bundle plugin for DSH Remote. It adds a DSH sidebar entry, a co
8
8
 
9
9
  ```sh
10
10
  dsh plugin --profile web add dsh-remote-plugin
11
+ dsh plugin --profile web list --depth 0
11
12
 
12
13
  # Optional version pin
13
14
  dsh plugin --profile web add dsh-remote-plugin@0.6.8
14
15
  ```
15
16
 
16
- Restart DSH Web and refresh the browser. The DSH Remote entry will appear in the sidebar.
17
+ The second command verifies installation in the `web` profile. Completely restart DSH Web and hard-refresh with Ctrl+F5. For a user service, a typical restart is `systemctl --user restart dsh-web`; for a manual setup, stop the old `dsh web` process and launch it again.
18
+
19
+ Open `http://127.0.0.1:8787/health` on the DSH host before pairing a phone. Once it returns JSON, connect the phone to `http://PC-LAN-IP:8787` or the host's Tailscale IP. Do not use `127.0.0.1` or `localhost` on the phone; those point to the phone itself.
17
20
 
18
21
  ## What the plugin provides
19
22
 
@@ -28,19 +31,36 @@ Restart DSH Web and refresh the browser. The DSH Remote entry will appear in the
28
31
 
29
32
  The Android app / mobile WebUI has five main destinations: Sessions, Files, Home, Stats, and Settings. Session detail supports goals, subagent interruption, model selection, fullscreen input, slash commands, and image attachments. Images can come from the camera or gallery and are sent as image content in `session.prompt`.
30
33
 
31
- Notification settings include approval / question notifications, background polling, peak reminders, task completion notices, and announcement history. Four themes are retained: Default Deep Space, Sunset, Elbphilharmonie, and Prairie Tower.
34
+ Notification settings include approval / question notifications, background polling, peak reminders, task completion notices, and announcement history. Announcements update from a central HTTPS source every 30 seconds while the app is in the foreground, with gateway-cache and bundled-file fallback. Four themes are retained: Default Deep Space, Sunset, Elbphilharmonie, and Prairie Tower.
32
35
 
33
36
  ## Gateway configuration
34
37
 
35
38
  - Port priority: `DSH_REMOTE_GATEWAY_PORT` → `~/.dsh-remote/gateway-port` → `8787`.
36
39
  - Token: `~/.dsh-remote/token`, generated on first run.
37
40
  - Self-healing: `~/.dsh-remote/gateway.enabled`; use `DSH_REMOTE_AUTOSTART=0` to disable automatic management.
38
- - File roots: `DSH_REMOTE_FS_ROOT`, separated by `:` on Linux/macOS and `;` on Windows.
41
+ - File roots: registered DSH workspaces are allowed automatically; configure other directories with `DSH_REMOTE_FS_ROOT`, separated by `:` on Linux/macOS and `;` on Windows.
39
42
  - Upload limit: `DSH_REMOTE_FS_MAX_UPLOAD`, 2 GB by default.
43
+ - Central announcements: override the default HTTPS endpoint with `DSH_REMOTE_ANNOUNCEMENTS_URL`, or set it to an empty string to disable the central source.
40
44
  - DSH upstream: `http://127.0.0.1:3080` by default.
41
45
 
42
46
  The token grants remote control of DSH. Keep it private. For cross-network access, prefer Tailscale or another authenticated secure tunnel.
43
47
 
48
+ ## Gateway cannot be opened
49
+
50
+ ```bash
51
+ curl -i http://127.0.0.1:8787/health
52
+ ss -ltnp | grep ':8787'
53
+ curl -i http://127.0.0.1:3080/
54
+ ```
55
+
56
+ - If port 8787 refuses locally, verify the `web` profile installation, gateway state in the plugin panel, a real DSH Web restart, and port ownership.
57
+ - If `/health` works but `upstreamOk` is false, the gateway is running; troubleshoot DSH Web on port 3080.
58
+ - If local access works but the phone fails, use the host LAN/Tailscale IP and permit inbound TCP 8787. Do not expose port 3080 publicly.
59
+ - A 401 response means the token is wrong; pair again or copy the current `~/.dsh-remote/token`.
60
+ - For a blank/stale page after an upgrade, hard-refresh or fully close and reopen the app.
61
+
62
+ The managed gateway may be a transient process. Do not rely on `systemctl --user restart dsh-remote-gateway.service`; use Start Gateway in the plugin panel or restart DSH Web to trigger self-healing.
63
+
44
64
  ## Links
45
65
 
46
66
  - Admin page: `http://<gateway-ip>:8787/admin`
package/README.md CHANGED
@@ -8,12 +8,15 @@ DSH Remote 的官方 bundle 插件:在 DSH 侧边栏提供入口,打开快
8
8
 
9
9
  ```sh
10
10
  dsh plugin --profile web add dsh-remote-plugin
11
+ dsh plugin --profile web list --depth 0
11
12
 
12
13
  # 也可以安装指定版本
13
14
  dsh plugin --profile web add dsh-remote-plugin@0.6.8
14
15
  ```
15
16
 
16
- 重启 DSH Web 后刷新浏览器,左侧边栏会出现 DSH Remote 入口。
17
+ 第二条命令用于确认插件安装在正确的 `web` profile。然后完整重启 DSH Web 进程并 Ctrl+F5 强刷浏览器,左侧边栏会出现 DSH Remote 入口。如果 DSH Web 由 systemd 用户服务管理,可执行 `systemctl --user restart dsh-web`;手动运行时则需停止旧的 `dsh web` 进程后重新启动。
18
+
19
+ 安装后先在 DSH 主机访问 `http://127.0.0.1:8787/health`。看到 JSON 后再用手机连接 `http://电脑局域网IP:8787`;手机上的 `127.0.0.1` 和 `localhost` 指向手机自己,不是 DSH 主机。
17
20
 
18
21
  ## 插件提供什么
19
22
 
@@ -28,19 +31,36 @@ dsh plugin --profile web add dsh-remote-plugin@0.6.8
28
31
 
29
32
  Android 应用 / 手机 WebUI 采用五个主要页面:会话、文件、主页、统计、设置。会话详情支持目标控制、子代理中断、模型切换、全屏输入、斜杠命令和图片附件。图片附件可从相机或相册选择,并以 `session.prompt` 图片内容发送到当前会话。
30
33
 
31
- 通知设置支持审批 / 提问通知、后台轮询、峰谷提醒、任务完成提醒和历史公告。当前保留四套主题:默认深空、落日、易北爱乐厅、草原孤塔。
34
+ 通知设置支持审批 / 提问通知、后台轮询、峰谷提醒、任务完成提醒和历史公告。公告通过中央 HTTPS 源更新,App 前台每 30 秒检查,中央源不可达时回退网关缓存或安装包内公告。当前保留四套主题:默认深空、落日、易北爱乐厅、草原孤塔。
32
35
 
33
36
  ## 网关配置
34
37
 
35
38
  - 网关端口优先级:`DSH_REMOTE_GATEWAY_PORT` 环境变量 → `~/.dsh-remote/gateway-port` → `8787`;
36
39
  - 令牌:`~/.dsh-remote/token`,首次运行自动生成;
37
40
  - 自愈开关:`~/.dsh-remote/gateway.enabled`,或使用 `DSH_REMOTE_AUTOSTART=0` 禁用自动管理;
38
- - 文件根目录:`DSH_REMOTE_FS_ROOT`,Linux/macOS 使用 `:`,Windows 使用 `;`;
41
+ - 文件根目录:DSH 已登记工作区会自动放行;其他目录用 `DSH_REMOTE_FS_ROOT` 配置,Linux/macOS 使用 `:`,Windows 使用 `;`;
39
42
  - 文件上限:`DSH_REMOTE_FS_MAX_UPLOAD`,默认 2GB;
43
+ - 中央公告:`DSH_REMOTE_ANNOUNCEMENTS_URL` 可覆盖默认 HTTPS 地址,设为空字符串可禁用中央源;
40
44
  - DSH 上游:默认 `http://127.0.0.1:3080`。
41
45
 
42
46
  令牌等同于 DSH 远程操作凭证,请不要公开或提交到仓库。局域网访问建议配合防火墙;跨网络访问建议使用 Tailscale 或其他带认证的安全隧道。
43
47
 
48
+ ## 网关打不开
49
+
50
+ ```bash
51
+ curl -i http://127.0.0.1:8787/health
52
+ ss -ltnp | grep ':8787'
53
+ curl -i http://127.0.0.1:3080/
54
+ ```
55
+
56
+ - 8787 本机也拒绝连接:检查插件是否装在 `web` profile、插件面板的网关开关、DSH Web 是否真正重启,以及 8787 是否被占用。
57
+ - `/health` 能打开但 `upstreamOk: false`:网关已运行,应检查 DSH Web 的 3080 端口。
58
+ - 本机能打开但手机不能:使用电脑局域网/Tailscale IP,并允许防火墙 TCP 8787 入站;不需要对公网放行 3080。
59
+ - 出现 401:重新扫码或复制 `~/.dsh-remote/token`。
60
+ - 页面黑屏/功能未更新:Ctrl+F5 强刷,手机端完全退出后重开。
61
+
62
+ 插件网关可能以 transient 进程运行,不要依赖 `systemctl --user restart dsh-remote-gateway.service`。优先在插件面板启动网关,或重启 DSH Web 触发自愈。
63
+
44
64
  ## 相关地址
45
65
 
46
66
  - 管理页:`http://<网关IP>:8787/admin`
Binary file
package/client.js CHANGED
@@ -24,6 +24,61 @@ window.__ModuleLoader__.load({
24
24
  // 动态注入列布局, 让各 footer action 上下堆叠。
25
25
  var FOOTER_FIX_ID = 'dsh-remote-footer-stack'
26
26
  var FOOTER_FIX_CSS = '.kIs9zW_footerActions{flex-direction:column!important;align-items:stretch!important}'
27
+ var PLUGIN_THEMES = ['default', 'dark', 'light', 'neutral']
28
+ var ICON_PALETTES = {
29
+ default: { bg: '#F6F9FF', upper: '#4F7FEF', lower: '#8A64D8' },
30
+ dark: { bg: '#FFF4E3', upper: '#C66A00', lower: '#8A5A24' },
31
+ light: { bg: '#FFF9EF', upper: '#9B6A20', lower: '#66517F' },
32
+ neutral: { bg: '#F4F0DF', upper: '#585818', lower: '#832D15' },
33
+ }
34
+
35
+ function currentPluginTheme() {
36
+ var saved = null
37
+ try { saved = localStorage.getItem('dshTheme') } catch (_) {}
38
+ if (PLUGIN_THEMES.indexOf(saved) >= 0) return saved
39
+ return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'default'
40
+ }
41
+
42
+ function usePluginTheme() {
43
+ var themeState = React.useState(currentPluginTheme)
44
+ var theme = themeState[0]
45
+ var setTheme = themeState[1]
46
+ React.useEffect(function () {
47
+ var mq = window.matchMedia('(prefers-color-scheme: light)')
48
+ function sync() { setTheme(currentPluginTheme()) }
49
+ function onStorage(e) { if (!e || e.key === 'dshTheme') sync() }
50
+ function onMessage(e) {
51
+ var d = e.data
52
+ if (e.origin !== location.origin || !d || d.source !== 'dsh-remote-theme' || d.type !== 'change') return
53
+ if (PLUGIN_THEMES.indexOf(d.theme) >= 0) setTheme(d.theme)
54
+ }
55
+ window.addEventListener('storage', onStorage)
56
+ window.addEventListener('message', onMessage)
57
+ if (mq.addEventListener) mq.addEventListener('change', sync)
58
+ else if (mq.addListener) mq.addListener(sync)
59
+ return function () {
60
+ window.removeEventListener('storage', onStorage)
61
+ window.removeEventListener('message', onMessage)
62
+ if (mq.removeEventListener) mq.removeEventListener('change', sync)
63
+ else if (mq.removeListener) mq.removeListener(sync)
64
+ }
65
+ }, [])
66
+ return theme
67
+ }
68
+
69
+ function PluginIcon() {
70
+ var palette = ICON_PALETTES[usePluginTheme()] || ICON_PALETTES.default
71
+ var common = {
72
+ fill: 'none', strokeWidth: 2.8, strokeLinecap: 'square', strokeLinejoin: 'miter',
73
+ }
74
+ return React.createElement('svg', {
75
+ viewBox: '0 0 24 24', 'aria-hidden': true, focusable: false,
76
+ style: { width: 16, height: 16, flexShrink: 0, display: 'block' },
77
+ },
78
+ React.createElement('rect', { x: .75, y: .75, width: 22.5, height: 22.5, rx: 5, fill: palette.bg, stroke: 'rgba(15, 23, 42, .14)', strokeWidth: 1 }),
79
+ React.createElement('path', Object.assign({ d: 'M10 6l4 4 4-4m-4 4 4 4', stroke: palette.upper }, common)),
80
+ React.createElement('path', Object.assign({ d: 'M6 10l4 4-4 4m4-4 4 4', stroke: palette.lower }, common)))
81
+ }
27
82
 
28
83
  function useFooterStack() {
29
84
  React.useEffect(function () {
@@ -59,10 +114,7 @@ window.__ModuleLoader__.load({
59
114
  font: '500 13px/1.3 system-ui, sans-serif', textAlign: 'left',
60
115
  },
61
116
  },
62
- React.createElement('img', {
63
- src: '/remote/icon.svg', alt: '', 'aria-hidden': true,
64
- style: { width: 16, height: 16, flexShrink: 0 },
65
- }),
117
+ React.createElement(PluginIcon),
66
118
  props.wide ? React.createElement('span', null, 'DSH Remote') : null)
67
119
  }
68
120
 
package/gateway-stats.cjs CHANGED
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * 聚合:
8
8
  * time(UTC 毫秒) -> Asia/Shanghai(固定 UTC+8, 无夏令时) -> 日 × 小时 × 模型
9
- * 时段: 高峰 9:00-12:00 / 14:00-18:00(含起点, 不含终点), 其余空闲
9
+ * 时段: 工作日高峰 9:00-12:00 / 14:00-18:00(含起点, 不含终点), 周末全天空闲
10
10
  * 四桶: input(未缓存输入) / cacheRead(缓存命中读) / cacheWrite(缓存写) / output(输出)
11
11
  *
12
12
  * 存储:
@@ -24,7 +24,7 @@ const { spawn } = require('node:child_process')
24
24
  const readline = require('node:readline')
25
25
 
26
26
  // ---------- 固定价格表(v1 硬编码; v2 将改为配置文件/环境变量) ----------
27
- // 时段判定: 北京时间 9:00-12:00 与 14:00-18:00 为高峰(含起点不含终点)
27
+ // 时段判定: 工作日北京时间 9:00-12:00 与 14:00-18:00 为高峰(含起点不含终点),周末全天谷时
28
28
  const PEAK_HOURS = [[9, 12], [14, 18]]
29
29
  const PRICES = {
30
30
  'deepseek-v4-flash': {
@@ -73,6 +73,19 @@ function periodOfHour(hour) {
73
73
  return 'off'
74
74
  }
75
75
 
76
+ /** 北京自然日是否为周六/周日。 */
77
+ function isWeekendDate(date) {
78
+ const parts = String(date || '').split('-').map(Number)
79
+ if (parts.length !== 3 || parts.some(n => !Number.isInteger(n))) return false
80
+ const day = new Date(Date.UTC(parts[0], parts[1] - 1, parts[2])).getUTCDay()
81
+ return day === 0 || day === 6
82
+ }
83
+
84
+ /** 北京日期 + 小时 -> 计费时段;周末全天谷时。 */
85
+ function periodOfDateHour(date, hour) {
86
+ return isWeekendDate(date) ? 'off' : periodOfHour(hour)
87
+ }
88
+
76
89
  /** 时段单价; 未知模型返回全 0(统计照记, 费用为 0)。 */
77
90
  function pricesFor(model) {
78
91
  return PRICES[model] || null
@@ -105,6 +118,11 @@ function addUsage(bucket, model, period, usage) {
105
118
  return bucket
106
119
  }
107
120
 
121
+ /** 按当前日期规则重算 bucket 费用,兼容升级前已按工作日价格写入的周末历史数据。 */
122
+ function pricedBucket(model, period, bucket) {
123
+ return addUsage(emptyBucket(), model, period, bucket)
124
+ }
125
+
108
126
  function mergeBucket(dst, src) {
109
127
  for (const key of ['input', 'cacheRead', 'cacheWrite', 'output', 'cost']) {
110
128
  dst[key] += src[key]
@@ -114,7 +132,9 @@ function mergeBucket(dst, src) {
114
132
 
115
133
  /** 时段事件 -> 日/小时/模型聚合的 key。 */
116
134
  function eventKey(timeMs) {
117
- return { date: beijingDate(timeMs), hour: beijingHour(timeMs), period: periodOfHour(beijingHour(timeMs)) }
135
+ const date = beijingDate(timeMs)
136
+ const hour = beijingHour(timeMs)
137
+ return { date, hour, period: periodOfDateHour(date, hour) }
118
138
  }
119
139
 
120
140
  /** 事件里的模型: 优先 message.source.model。 */
@@ -143,9 +163,10 @@ function summarizeDay(day) {
143
163
  const off = emptyBucket()
144
164
  for (const hourStr of Object.keys(day?.hours || {})) {
145
165
  const hour = Number(hourStr)
146
- const target = periodOfHour(hour) === 'peak' ? peak : off
166
+ const period = periodOfDateHour(day?.date, hour)
167
+ const target = period === 'peak' ? peak : off
147
168
  for (const model of Object.keys(day.hours[hourStr] || {})) {
148
- mergeBucket(target, day.hours[hourStr][model])
169
+ mergeBucket(target, pricedBucket(model, period, day.hours[hourStr][model]))
149
170
  }
150
171
  }
151
172
  const total = emptyBucket()
@@ -403,11 +424,12 @@ class StatsStore {
403
424
  const s = dayTotals(day)
404
425
  const byModel = {}
405
426
  for (const hourStr of Object.keys(day.hours || {})) {
406
- const period = periodOfHour(Number(hourStr))
427
+ const period = periodOfDateHour(date, Number(hourStr))
407
428
  for (const [model, bucket] of Object.entries(day.hours[hourStr])) {
408
429
  const m = byModel[model] || (byModel[model] = { peak: emptyBucket(), off: emptyBucket(), total: emptyBucket() })
409
- mergeBucket(m[period], bucket)
410
- mergeBucket(m.total, bucket)
430
+ const priced = pricedBucket(model, period, bucket)
431
+ mergeBucket(m[period], priced)
432
+ mergeBucket(m.total, priced)
411
433
  }
412
434
  }
413
435
  out.push({ date, ...s, byModel })
@@ -421,10 +443,14 @@ class StatsStore {
421
443
  const day = this._loadDay(effective)
422
444
  const hours = []
423
445
  for (let hour = 0; hour < 24; hour++) {
424
- const models = day.hours[hour] || {}
446
+ const period = periodOfDateHour(effective, hour)
447
+ const models = {}
425
448
  const total = emptyBucket()
426
- for (const bucket of Object.values(models)) mergeBucket(total, bucket)
427
- hours.push({ hour, period: periodOfHour(hour), models, total })
449
+ for (const [model, bucket] of Object.entries(day.hours[hour] || {})) {
450
+ models[model] = pricedBucket(model, period, bucket)
451
+ mergeBucket(total, models[model])
452
+ }
453
+ hours.push({ hour, period, models, total })
428
454
  }
429
455
  return { date: effective, pricingStart: PRICING_START_DATE, hours }
430
456
  }
@@ -438,8 +464,11 @@ module.exports = {
438
464
  beijingHour,
439
465
  beijingDate,
440
466
  periodOfHour,
467
+ isWeekendDate,
468
+ periodOfDateHour,
441
469
  emptyBucket,
442
470
  addUsage,
471
+ pricedBucket,
443
472
  summarizeDay,
444
473
  dayTotals,
445
474
  eventKey,