dsh-harmonyos-hiboard 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,6 +6,7 @@
6
6
  [![HarmonyOS NEXT](https://img.shields.io/badge/HarmonyOS-NEXT-000000?logo=huawei&logoColor=white)](#)
7
7
  [![dsh](https://img.shields.io/badge/DeepSeek%20Harness-0.1.5%20%7C%200.2-4B6BFB)](#)
8
8
  [![Node](https://img.shields.io/badge/Node.js-18%2B-339933?logo=node.js&logoColor=white)](#)
9
+ [![npm](https://img.shields.io/npm/v/dsh-harmonyos-hiboard)](https://www.npmjs.com/package/dsh-harmonyos-hiboard)
9
10
  [![Listed on dsh-plugin.org](https://dsh-plugin.org/badges/listed.svg)](https://dsh-plugin.org/plugins/maybemeibemaybi/dsh-harmonyos-hiboard)
10
11
 
11
12
  把 **DeepSeek Harness(dsh)** 的对话与任务结果,以 **服务卡片** 形式推送到华为鸿蒙
@@ -117,10 +118,17 @@ TypeError: Cannot read properties of undefined (reading 'validate')
117
118
  前置:Windows / macOS / Linux + Node.js 18+ + dsh 已安装 + `pnpm` 已安装。
118
119
 
119
120
  ```bash
120
- # 1) 安装为 dsh 插件(从 GitHub 安装,跨平台)
121
+ # 1) 安装为 dsh 插件
122
+ # 推荐走 npm:预构建安装,免掉 allowBuilds 构建授权那一步
123
+ dsh plugin --profile web add dsh-harmonyos-hiboard
124
+
125
+ # 或者从 GitHub 安装
121
126
  dsh plugin --profile web add github:MaybeMeibeMaybi/dsh-harmonyos-hiboard
122
127
  ```
123
128
 
129
+ > 两者装的是同一份代码。npm 上的是本仓库的构建产物,`repository` 字段指回本仓库;
130
+ > 想装某个具体版本就写 `dsh-harmonyos-hiboard@0.6.0`。
131
+
124
132
  <details>
125
133
  <summary>或者从本地克隆安装(改源码 / 调试时用)</summary>
126
134
 
@@ -134,7 +142,8 @@ dsh plugin --profile web add "file:<不含空格的路径>"
134
142
 
135
143
  **2) 填授权码** —— 两种方式,任选其一。
136
144
 
137
- **方式 A(推荐):图形界面。** 重启 dsh 后打开 **设置 → 插件 → `dsh-harmonyos-hiboard`**,
145
+ **方式 A(推荐):图形界面。** 重启 dsh 后打开 **主界面左侧栏的「插件」面板 →
146
+ `dsh-harmonyos-hiboard` → 点进它的页面**(**不在「设置」里**,见下方「设置卡片」一节),
138
147
  在「推送设置」卡片里填写。输入框**两种写法都认**:
139
148
 
140
149
  ```text
@@ -191,14 +200,30 @@ L2 抢救"承诺了却没调用"、L3 按错误类别重试并自动补 `schedul
191
200
 
192
201
  ---
193
202
 
194
- ## 设置页(插件卡片)
203
+ ## 设置卡片(在「插件」面板里)
204
+
205
+ 打开 **主界面左侧栏的「插件」面板 → `dsh-harmonyos-hiboard` → 点进它的页面**,
206
+ 「推送设置」卡片就在简介与条目行之间。
207
+
208
+ > **注意不是「设置」里。** dsh 的设置导航里那个「内置插件」页只提供只读清单与市场页签,
209
+ > 它**不声明** `plugins.bundle.config` 插槽 —— 这条卡片永远不可能出现在那里。
210
+ > 承载配置卡片的是 `dsh-client-ui-plugin-manager`(侧栏面板 id `plugins`,文案「插件」),
211
+ > 本插件与 modsearch 的卡片都在同一个位置。
212
+ > 新装或升级后如果没看到卡片,**先刷新页面**;**版本升级还需重启 dsh**(已挂载的插件不会被热替换)。
195
213
 
196
- 打开 **设置 → 插件 → `dsh-harmonyos-hiboard`**,卡片提供两件事:
214
+ 卡片提供三件事:
197
215
 
198
216
  1. **授权码输入框** —— 裸授权码与整条关联指令都认(自动取末尾的码),保存前显示打码预览,
199
217
  保存后**立即生效**;卡片还显示**当前生效来源**(设置页 / 补丁层 / 环境变量),
200
218
  避免"改了一处却不生效"。点「清除」删掉设置页那份,回退到下一层。
201
219
  2. **推送兜底开关** —— **默认开启**,关掉**立即生效**(不必重启)。
220
+ 文案里也直说了:建议保持开启。
221
+ 3. **兜底战绩** —— 列出**「原本没推出去、但兜底救回来了」**的次数与最近几次,
222
+ 数据来自兜底自己的审计流水(`<状态目录>/audit.jsonl`):
223
+ - `retry-ok`:工具真的调用了但失败,按错误类别重试后成功
224
+ - `rescue-ok`:根本没调用工具(写成纯文本 / 只承诺没做),兜底代为补推
225
+
226
+ 这个面板的意义是:以前"保险丝到底有没有干活"只能靠猜,现在直接列给你看。
202
227
 
203
228
  配置写在 `<用户目录>/.dsh/hiboard-push/config.json`(0600,先写临时文件再原子替换)。
204
229
  该文件属于当前系统用户,本机所有 dsh profile 共用一份。
@@ -350,6 +375,35 @@ npm test
350
375
 
351
376
  ---
352
377
 
378
+ ## 新环境部署清单(怎么对齐到「每轮都推送」)
379
+
380
+ 换一台电脑、或让别人从零装一遍,**装完插件只是一半**。完整清单:
381
+
382
+ | # | 做什么 | 不做会怎样 |
383
+ |---|---|---|
384
+ | 1 | 装插件:`dsh plugin --profile web add dsh-harmonyos-hiboard` | 没有工具 |
385
+ | 2 | 配授权码(设置卡片,或 profile 补丁层) | 调用时报「未配置授权码」 |
386
+ | 3 | **把推送规则写进 `~/.dsh/AGENTS.md`** | 功能在,但**没人调用它** —— 每轮都不会推 |
387
+ | 4 | 重启 dsh | 新装/升级的插件不生效(升级不会热替换已挂载插件) |
388
+
389
+ 第 3 步的现成模板见 **[`docs/AGENTS-rule.md`](docs/AGENTS-rule.md)**:dsh 由
390
+ `dsh-agent-instructions` 在**每个会话**自动加载 `$DSH_HOME/AGENTS.md`,写在那里就是全局生效。
391
+
392
+ > ⚠️ **「每轮必定推送」不是插件能保证的事,要如实理解。**
393
+ > `hiboard_push` 是一个**工具**,工具不会自己调用 —— 调不调用由模型按指令决定。
394
+ > 内置兜底把「漏推」的概率压得很低,但它是**启发式**,不是硬保证:
395
+ > - L1/L2 需要正文里**看得出推送意图**才会补推(保守优先,宁可少推也不乱推);
396
+ > - L3 只对**可重试**的失败生效(授权码无效、内容超长这类永久错误不重试);
397
+ > - 授权码未配置、或端点处于坏窗口且重试预算用尽时,仍然推不出去。
398
+ >
399
+ > 准确的表述是:**规则 + 兜底让「每轮都推」在实践上基本成立**,而不是被保证。
400
+
401
+ **不必迁移、与本插件无关的东西**:本机网络入口(局域网 3081 / tailnet 8443 / 公网 18443)、
402
+ token 广播、Steam++ 之类的 hosts 加速器 —— 那些属于本机环境。
403
+ 本插件只需要:Node 18+、pnpm、一台能取到授权码的华为手机。
404
+
405
+ ---
406
+
353
407
  ## 兼容性
354
408
 
355
409
  | 组件 | 版本 |
package/docs/DEPLOY.md CHANGED
@@ -51,7 +51,8 @@ dsh plugin --profile web add "file:E:/dsh-vendor/dsh-harmonyos-hiboard"
51
51
 
52
52
  ## 第 3 步:写入授权码
53
53
 
54
- **推荐做法(图形界面)**:不用命令行 —— 打开 **设置 → 插件 → `dsh-harmonyos-hiboard`**,
54
+ **推荐做法(图形界面)**:不用命令行 —— 打开 **主界面侧栏的「插件」面板 →
55
+ `dsh-harmonyos-hiboard` → 点进它的页面**(**不在「设置」里**,见 README「设置卡片」一节),
55
56
  在「推送设置」卡片里填。输入框**裸授权码**和**整条关联指令**
56
57
  (`openclaw config set skills.entries.today-task.config.authCode <AUTH_CODE>`)都认,
57
58
  保存后立即生效,并显示当前生效来源。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-harmonyos-hiboard",
3
- "version": "0.6.0",
4
- "description": "Push DeepSeek Harness (dsh) task results to Huawei HarmonyOS assistant-today service cards, with a settings card that accepts either the bare authCode or the whole OpenClaw linking command, plus a built-in push safety net (rescue / promise rescue / classified retry) that is on by default. Fixed for dsh 0.1.5 / cordis 4 and compatible with dsh 0.2 (peer-range gate).",
3
+ "version": "0.7.1",
4
+ "description": "把DSH的任务结果,以服务卡片形式推送到华为鸿蒙负一屏\nPush DSH task results to the Huawei HarmonyOS assistant-today screen as service cards.",
5
5
  "author": "Jim Chen",
6
6
  "license": "MIT",
7
7
  "repository": {
package/src/lib/client.js CHANGED
@@ -1,33 +1,36 @@
1
1
  // dsh-harmonyos-hiboard 的浏览器半边:插件设置卡片。
2
2
  //
3
- // dsh 不会从设置 schema 自动生成表单,所以卡片必须**作为插槽内容贡献**,
4
- // 而不是声明出来:从 dsh 0.1.6-alpha.2 起挂在 `plugins.bundle.config`
5
- // (「插件」页里该 bundle 自己的页面上),更早的版本挂在设置页的
6
- // `settings.plugin.item`。这里两个都挂,宿主声明哪个就填哪个。
3
+ // dsh 不会从设置 schema 自动生成表单,所以卡片必须**作为插槽内容贡献**:
4
+ // 从 dsh 0.1.6-alpha.2 起挂在 `plugins.bundle.config`(主界面侧栏「插件」面板里
5
+ // 该 bundle 自己的页面上),更早的版本挂在设置页的 `settings.plugin.item`。
6
+ // 两个都挂,宿主声明哪个就填哪个。
7
7
  //
8
- // 手写在 lazy-CJS bundle 协议里(window.__ModuleLoader__.load + 返回 cordis
9
- // 插件导出的 factory),所以**没有构建步骤**,也不 import 任何 dsh 客户端包 ——
10
- // 与宿主半边同样的零依赖立场。
8
+ // 手写在 lazy-CJS bundle 协议里(window.__ModuleLoader__.load + 返回 cordis 插件
9
+ // 导出的 factory),所以**没有构建步骤**。
10
+ //
11
+ // 控件一律用官方原语(Button / Switch / Input):它们的配色由
12
+ // `--dsw-alias-button-*` 等主题令牌驱动,深浅色主题都自动正确。
13
+ // 第一版这些控件是手搓样式(自己拼 background / color),这正是它们在深色模式下
14
+ // 不可读的原因 —— 所以这里不再自己决定控件配色。
11
15
  //
12
16
  // 它读写宿主半边的 `/hiboard-push/config`:授权码存进插件自己的配置文件,
13
- // 浏览器永远拿不到已保存的值(只拿打码与来源),留空保存也不会把已存的值清掉。
17
+ // 浏览器永远拿不到已保存的明文(只拿打码值与来源)。
14
18
  window.__ModuleLoader__.load({
15
19
  id: 'dsh-harmonyos-hiboard',
16
20
  factory: (require) => {
17
21
  var module = { exports: {} };
18
22
  var exports = module.exports;
19
23
 
20
- // 「插件」页按 npm 包名分发某个 bundle 的配置,这个 key 必须与包名一致。
24
+ // 「插件」面板按 npm 包名分发某个 bundle 的配置,这个 key 必须与包名一致。
21
25
  var PACKAGE_NAME = 'dsh-harmonyos-hiboard';
22
26
  var ROUTE = '/hiboard-push/config';
23
27
 
24
28
  // 授权码的字符集与长度区间 —— 与宿主半边 src/lib/authcode.js 同一套规则。
25
29
  // 客户端 bundle 无法 import 宿主模块,所以这里是**刻意的副本**:
26
- // 两边规则必须一起改,改动点在此注明。
30
+ // 两边规则必须一起改,仓库里有对齐测试盯着(test/authcode.test.mjs)。
27
31
  var CODE_PATTERN = /^[A-Za-z0-9_-]{6,128}$/;
28
32
  var COMMAND_HINTS = /(openclaw|config\s+set|skills\.entries|today-task|authCode)/i;
29
33
 
30
- // 解析用户输入:整条关联指令或裸授权码都认(取指令的最后一个词)。
31
34
  function parseAuthCodeInput(raw) {
32
35
  var original = typeof raw === 'string' ? raw : '';
33
36
  if (!original.trim()) return { ok: false, reason: 'empty' };
@@ -45,26 +48,26 @@ window.__ModuleLoader__.load({
45
48
  var tokens = text.split(/\s+/).filter(Boolean);
46
49
  var last = tokens[tokens.length - 1].replace(/^[`'"]+/, '').replace(/[`'"]+$/, '');
47
50
  if (!CODE_PATTERN.test(last)) return { ok: false, reason: 'invalid' };
48
- return {
49
- ok: true,
50
- code: last,
51
- shape: 'command',
52
- // 不像常见指令时也照取最后一个词,但让界面说清楚这是"猜"的
53
- guessed: !COMMAND_HINTS.test(text)
54
- };
51
+ return { ok: true, code: last, shape: 'command', guessed: !COMMAND_HINTS.test(text) };
55
52
  }
56
53
 
57
54
  function maskAuthCode(code) {
58
55
  var s = typeof code === 'string' ? code : '';
59
56
  if (!s) return '';
60
57
  if (s.length <= 4) return new Array(s.length + 1).join('•');
61
- var dots = new Array(Math.min(s.length - 4, 12) + 1).join('•');
62
- return dots + s.slice(-4);
58
+ return new Array(Math.min(s.length - 4, 12) + 1).join('•') + s.slice(-4);
59
+ }
60
+
61
+ // 审计时间是 ISO 串;面板只想要「最近几次」,显示到分钟就够。
62
+ function shortTime(iso) {
63
+ var s = String(iso || '');
64
+ if (s.length < 16) return s;
65
+ return s.slice(5, 10).replace('-', '/') + ' ' + s.slice(11, 16);
63
66
  }
64
67
 
65
68
  // ------------------------------------------------------------ 文案
66
- // 两组短标签而不是一套 locale bundle:卡片上就这些字,
67
- // 为它们装一套本地化机制比它标注的东西还重。
69
+ // 两组短标签而不是一套 locale bundle:卡片上就这些字,为它们装一套
70
+ // 本地化机制比它标注的东西还重。
68
71
  var TEXT = {
69
72
  zh: {
70
73
  section: '推送设置',
@@ -74,21 +77,32 @@ window.__ModuleLoader__.load({
74
77
  source: '当前生效来源',
75
78
  placeholder: '粘贴授权码,或整条关联指令…',
76
79
  save: '保存',
77
- saving: '保存中…',
78
80
  clear: '清除',
79
81
  commandHint:
80
- '可以直接粘贴整条关联指令,例如 openclaw config set skills.entries.today-task.config.authCode <授权码> —— 会自动从末尾取出授权码。',
82
+ '也可以直接粘贴整条关联指令,例如 openclaw config set skills.entries.today-task.config.authCode <授权码>,会自动从末尾取出授权码。',
81
83
  willExtract: '将从这条指令中取出授权码:',
82
84
  bareOk: '将按裸授权码保存:',
83
85
  invalid: '这串内容不像授权码(只允许字母、数字、下划线、连字符,6–128 位)。',
84
86
  emptyErr: '请先填入授权码或整条关联指令。',
85
87
  whereHint:
86
- '授权码获取:手机 负一屏 → 我的 → 动态管理 → 关联账号 → Claw 智能体。它不会存进浏览器,字段在保存后清空。',
87
- guardTitle: '推送兜底(保险丝)',
88
+ '授权码获取:手机 负一屏 → 我的 → 动态管理 → 关联账号 → Claw 智能体。不存进浏览器,字段保存后清空。',
89
+ guardTitle: '推送兜底',
88
90
  guardOn: '已开启',
89
91
  guardOff: '已关闭',
90
- guardHint:
91
- 'L1 抢救"被写成纯文本的调用"、L2 抢救"承诺了推送却没调用"、L3 按错误类别重试并自动补 schedule_id。默认开启,关掉立即生效。',
92
+ guardHint: '默认开启,建议保持开启。模型漏调用或调用失败时,它替你补上,卡片就不会悄悄丢掉。',
93
+ guardWhat:
94
+ '① 模型把调用写成了纯文本 → 替你补推 ② 模型只说“推给你”却没真调用 → 替你补推 ③ 调用失败 → 按错误类型重试',
95
+ guardBudget: function (g) {
96
+ return '重试预算:确定未送达 ' + g.retryCount + ' 次 / 可能已送达 ' + g.ambiguousRetryCount + ' 次 · schedule_id 兜底:' + g.defaultScheduleId;
97
+ },
98
+ debugTitle: '兜底战绩',
99
+ debugSummary: function (c) {
100
+ return '已救回 ' + (c.retryOk + c.rescueOk) + ' 次(重试 ' + c.retryOk + ' · 补推 ' + c.rescueOk + ')';
101
+ },
102
+ debugEmpty: '还没有需要兜底的记录 —— 每次推送都成功调用了工具。',
103
+ debugNone: '兜底还没有运行记录。',
104
+ kindRetry: '重试',
105
+ kindRescue: '补推',
92
106
  loadFailed: '读取配置失败',
93
107
  saveFailed: '保存失败',
94
108
  unavailable: '设置路由不可用(宿主未提供)。'
@@ -101,29 +115,39 @@ window.__ModuleLoader__.load({
101
115
  source: 'Effective source',
102
116
  placeholder: 'Paste the authCode, or the whole linking command…',
103
117
  save: 'Save',
104
- saving: 'saving…',
105
118
  clear: 'Clear',
106
119
  commandHint:
107
- 'You may paste the whole linking command, e.g. openclaw config set skills.entries.today-task.config.authCode <code> — the code is taken from the end.',
120
+ 'You may also paste the whole linking command, e.g. openclaw config set skills.entries.today-task.config.authCode <code>; the code is taken from the end.',
108
121
  willExtract: 'Auth code taken from that command: ',
109
122
  bareOk: 'Will be saved as a bare auth code: ',
110
123
  invalid: 'That does not look like an auth code (letters, digits, underscore, hyphen; 6–128).',
111
124
  emptyErr: 'Enter an auth code or the whole linking command first.',
112
125
  whereHint:
113
- 'Get the code on the phone: 负一屏 → 我的 → 动态管理 → 关联账号 → Claw 智能体. It is never stored in the browser; the field clears after saving.',
126
+ 'Get the code on the phone: 负一屏 → 我的 → 动态管理 → 关联账号 → Claw 智能体. Never stored in the browser; the field clears after saving.',
114
127
  guardTitle: 'Push safety net',
115
128
  guardOn: 'on',
116
129
  guardOff: 'off',
117
- guardHint:
118
- 'L1 rescues a push emitted as literal text, L2 rescues a promised but never-made push, L3 retries by failure class and backfills schedule_id. On by default; switching it off takes effect immediately.',
130
+ guardHint: 'On by default and best left on. When the model misses the call or a call fails, it steps in so a card is not silently lost.',
131
+ guardWhat:
132
+ '1) the model wrote the call as plain text → it pushes for you 2) the model promised a push but never called → it pushes for you 3) a call failed → it retries by error type',
133
+ guardBudget: function (g) {
134
+ return 'Retry budget: definitely-undelivered ' + g.retryCount + ' / possibly-delivered ' + g.ambiguousRetryCount + ' · schedule_id fallback: ' + g.defaultScheduleId;
135
+ },
136
+ debugTitle: 'Rescues so far',
137
+ debugSummary: function (c) {
138
+ return 'Saved ' + (c.retryOk + c.rescueOk) + ' (retries ' + c.retryOk + ', pushes ' + c.rescueOk + ')';
139
+ },
140
+ debugEmpty: 'Nothing needed rescuing yet — every push called the tool successfully.',
141
+ debugNone: 'The safety net has no run records yet.',
142
+ kindRetry: 'retry',
143
+ kindRescue: 'rescue',
119
144
  loadFailed: 'Could not load the configuration',
120
145
  saveFailed: 'Could not save',
121
146
  unavailable: 'The settings route is unavailable (host did not provide it).'
122
147
  }
123
148
  };
124
149
 
125
- // dsh 的语言:优先问 locale 服务(可能晚于卡片就绪,故在渲染时读),
126
- // 其次看页面 lang,最后看浏览器语言。
150
+ /** dsh 的语言:优先问 locale 服务(可能晚于卡片就绪,故在渲染时读),其次页面 lang。 */
127
151
  function pickLang(localeRef) {
128
152
  try {
129
153
  var locale = localeRef && localeRef.current;
@@ -144,28 +168,27 @@ window.__ModuleLoader__.load({
144
168
  }
145
169
 
146
170
  // ------------------------------------------------------------ 卡片
147
- // 用原生元素 + dsw 的 CSS 变量,而不是 @deepseek-ai/dsh-client-ui-primitives:
148
- // 少一个需要跟随宿主版本变化的接口面。这些变量在 dsh web 的主题里都有定义,
149
- // 取不到时用中性回退值,卡片在别的宿主上也不至于看不清。
150
- function Card(react, localeRef) {
171
+ function Card(react, ui, localeRef) {
151
172
  var h = react.createElement;
152
173
  var useState = react.useState;
153
174
  var useEffect = react.useEffect;
175
+ var Button = ui.Button;
176
+ var Switch = ui.Switch;
177
+ var Input = ui.Input;
154
178
 
179
+ // 只留排版用的中性令牌:**控件配色全部交给官方原语**,免得深浅色各错一次。
155
180
  var V = {
156
- label: 'var(--dsw-alias-label-primary, currentColor)',
157
181
  sub: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.9))',
158
182
  border: 'var(--dsw-alias-border-l2, rgba(127,127,127,0.28))',
159
- bg: 'var(--dsw-alias-bg-layer-2, rgba(127,127,127,0.06))',
160
- accent: 'var(--dsw-alias-brand-primary, #4b6bfb)',
161
- danger: 'var(--dsw-alias-label-error, #d9534f)'
183
+ danger: 'var(--dsw-alias-label-error, #d9534f)',
184
+ ok: 'var(--dsw-alias-label-success, #3ba55d)'
162
185
  };
163
186
 
164
187
  return function HiboardConfigCard() {
165
188
  var t = TEXT[pickLang(localeRef)];
166
- var summaryState = useState({ loading: true, data: null, error: '' });
167
- var summary = summaryState[0];
168
- var setSummary = summaryState[1];
189
+ var state = useState({ loading: true, data: null, error: '' });
190
+ var summary = state[0];
191
+ var setSummary = state[1];
169
192
  var draftState = useState('');
170
193
  var draft = draftState[0];
171
194
  var setDraft = draftState[1];
@@ -175,6 +198,9 @@ window.__ModuleLoader__.load({
175
198
  var noteState = useState('');
176
199
  var note = noteState[0];
177
200
  var setNote = noteState[1];
201
+ var showDebugState = useState(true);
202
+ var showDebug = showDebugState[0];
203
+ var setShowDebug = showDebugState[1];
178
204
 
179
205
  var refresh = function () {
180
206
  return fetch(ROUTE, { headers: { accept: 'application/json' } })
@@ -182,26 +208,25 @@ window.__ModuleLoader__.load({
182
208
  if (!response.ok) throw new Error('HTTP ' + response.status);
183
209
  return response.json();
184
210
  })
185
- .then(function (data) {
186
- setSummary({ loading: false, data: data, error: '' });
187
- })
211
+ .then(function (data) { setSummary({ loading: false, data: data, error: '' }); })
188
212
  .catch(function (error) {
189
213
  setSummary({ loading: false, data: null, error: String((error && error.message) || error) });
190
214
  });
191
215
  };
192
216
 
193
217
  useEffect(function () {
194
- // 路由不存在(settingsCard: false,或非 web profile)时只提示,
195
- // 不假装卡片生效 —— 那正是"关掉一个功能"该有的样子。
196
- fetch(ROUTE, { method: 'GET' }).then(function (response) {
197
- if (response.status === 404) {
198
- setSummary({ loading: false, data: null, error: t.unavailable });
199
- return;
200
- }
201
- refresh();
202
- }).catch(function (error) {
203
- setSummary({ loading: false, data: null, error: String((error && error.message) || error) });
204
- });
218
+ // 路由不存在(settingsCard: false,或非 web profile)时只提示,不假装卡片生效
219
+ fetch(ROUTE, { method: 'GET' })
220
+ .then(function (response) {
221
+ if (response.status === 404) {
222
+ setSummary({ loading: false, data: null, error: t.unavailable });
223
+ return;
224
+ }
225
+ refresh();
226
+ })
227
+ .catch(function (error) {
228
+ setSummary({ loading: false, data: null, error: String((error && error.message) || error) });
229
+ });
205
230
  }, []);
206
231
 
207
232
  var post = function (body) {
@@ -216,10 +241,7 @@ window.__ModuleLoader__.load({
216
241
  return data;
217
242
  });
218
243
  })
219
- .then(function (data) {
220
- setSummary({ loading: false, data: data, error: '' });
221
- return data;
222
- })
244
+ .then(function (data) { setSummary({ loading: false, data: data, error: '' }); return data; })
223
245
  .catch(function (error) {
224
246
  setSummary(function (prev) {
225
247
  return { loading: false, data: prev.data, error: String((error && error.message) || error) };
@@ -228,18 +250,14 @@ window.__ModuleLoader__.load({
228
250
  });
229
251
  };
230
252
 
231
- // 输入预览:保存前就告诉用户"会存进去什么",避免把整段说明当授权码存了
232
253
  var parsed = draft.trim() ? parseAuthCodeInput(draft) : null;
254
+ var info = summary.data && summary.data.authCode;
255
+ var guard = summary.data && summary.data.guard;
256
+ var audit = summary.data && summary.data.audit;
233
257
 
234
258
  var onSave = function () {
235
- if (!draft.trim()) {
236
- setNote(t.emptyErr);
237
- return;
238
- }
239
- if (parsed && !parsed.ok) {
240
- setNote(t.invalid);
241
- return;
242
- }
259
+ if (!draft.trim()) { setNote(t.emptyErr); return; }
260
+ if (parsed && !parsed.ok) { setNote(t.invalid); return; }
243
261
  setBusy('auth');
244
262
  setNote('');
245
263
  post({ authCode: draft }).then(function (data) {
@@ -249,7 +267,6 @@ window.__ModuleLoader__.load({
249
267
  setNote((data.notes && data.notes[0]) || '');
250
268
  });
251
269
  };
252
-
253
270
  var onClear = function () {
254
271
  setBusy('auth');
255
272
  setNote('');
@@ -260,38 +277,34 @@ window.__ModuleLoader__.load({
260
277
  setNote((data.notes && data.notes[0]) || '');
261
278
  });
262
279
  };
263
-
264
- var onToggleGuard = function () {
265
- var next = !(summary.data && summary.data.guard && summary.data.guard.enabled);
280
+ var onToggleGuard = function (next) {
266
281
  setBusy('guard');
267
282
  setNote('');
268
- post({ guardEnabled: next }).then(function () {
269
- setBusy('');
270
- });
283
+ post({ guardEnabled: next }).then(function () { setBusy(''); });
271
284
  };
272
285
 
273
- var info = summary.data && summary.data.authCode;
274
- var guard = summary.data && summary.data.guard;
275
-
276
286
  var row = function (children, extra) {
277
287
  return h(
278
288
  'div',
279
- { style: Object.assign({ display: 'flex', alignItems: 'center', gap: '8px' }, extra || {}) },
289
+ { style: Object.assign({ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }, extra || {}) },
280
290
  children
281
291
  );
282
292
  };
293
+ var muted = function (text, extra) {
294
+ return h('div', { style: Object.assign({ fontSize: '12px', lineHeight: '1.7', color: V.sub }, extra || {}) }, text);
295
+ };
296
+ var heading = function (text) {
297
+ return h('div', { style: { fontWeight: 600, marginBottom: '6px' } }, text);
298
+ };
283
299
 
284
300
  var statusDot = h('span', {
285
301
  style: {
286
- width: '8px',
287
- height: '8px',
288
- borderRadius: '50%',
289
- flex: 'none',
290
- background: info && info.configured ? '#3ba55d' : V.danger
302
+ width: '8px', height: '8px', borderRadius: '50%', flex: 'none',
303
+ background: info && info.configured ? V.ok : V.danger
291
304
  }
292
305
  });
293
306
 
294
- var input = h('input', {
307
+ var input = h(Input, {
295
308
  type: 'text',
296
309
  value: draft,
297
310
  placeholder: t.placeholder,
@@ -300,89 +313,37 @@ window.__ModuleLoader__.load({
300
313
  disabled: busy === 'auth',
301
314
  onChange: function (event) { setDraft(event.target.value); },
302
315
  onKeyDown: function (event) { if (event.key === 'Enter') onSave(); },
303
- style: {
304
- flex: '1 1 auto',
305
- minWidth: '0',
306
- padding: '6px 8px',
307
- fontSize: '13px',
308
- fontFamily: 'inherit',
309
- color: V.label,
310
- background: V.bg,
311
- border: '1px solid ' + V.border,
312
- borderRadius: '6px',
313
- outline: 'none'
314
- }
316
+ style: { flex: '1 1 220px', minWidth: '0' }
315
317
  });
316
318
 
317
- var btn = function (label, onClick, opts) {
318
- var o = opts || {};
319
- return h(
320
- 'button',
321
- {
322
- type: 'button',
323
- onClick: onClick,
324
- disabled: Boolean(busy) || Boolean(o.disabled),
325
- style: {
326
- flex: 'none',
327
- padding: '6px 12px',
328
- fontSize: '13px',
329
- fontFamily: 'inherit',
330
- cursor: busy ? 'default' : 'pointer',
331
- borderRadius: '6px',
332
- border: o.primary ? '1px solid transparent' : '1px solid ' + V.border,
333
- background: o.primary ? V.accent : 'transparent',
334
- color: o.primary ? '#fff' : V.label,
335
- opacity: busy || o.disabled ? 0.6 : 1
336
- }
337
- },
338
- label
339
- );
340
- };
341
-
342
319
  var preview = null;
343
320
  if (parsed && parsed.ok) {
344
- preview = h(
345
- 'div',
346
- { style: { marginTop: '6px', fontSize: '12px', color: V.sub } },
347
- (parsed.shape === 'command' ? t.willExtract : t.bareOk) + maskAuthCode(parsed.code)
348
- );
321
+ preview = muted((parsed.shape === 'command' ? t.willExtract : t.bareOk) + maskAuthCode(parsed.code), { marginTop: '6px' });
349
322
  } else if (parsed && !parsed.ok) {
350
323
  preview = h('div', { style: { marginTop: '6px', fontSize: '12px', color: V.danger } }, t.invalid);
351
324
  }
352
325
 
353
- // 兜底开关:原生 checkbox 画成开关样式,少依赖一个宿主原语
354
- var guardSwitch = h(
355
- 'button',
356
- {
357
- type: 'button',
358
- role: 'switch',
359
- 'aria-checked': Boolean(guard && guard.enabled),
360
- disabled: busy === 'guard' || !guard,
361
- onClick: onToggleGuard,
362
- style: {
363
- flex: 'none',
364
- width: '42px',
365
- height: '22px',
366
- padding: '2px',
367
- cursor: busy === 'guard' || !guard ? 'default' : 'pointer',
368
- borderRadius: '11px',
369
- border: '1px solid ' + V.border,
370
- background: guard && guard.enabled ? V.accent : 'transparent',
371
- display: 'flex',
372
- justifyContent: guard && guard.enabled ? 'flex-end' : 'flex-start',
373
- transition: 'background .14s'
374
- }
375
- },
376
- h('span', {
377
- style: {
378
- width: '16px',
379
- height: '16px',
380
- borderRadius: '50%',
381
- background: '#fff',
382
- boxShadow: '0 1px 2px rgba(0,0,0,.3)'
383
- }
384
- })
385
- );
326
+ // ---- 兜底战绩:把「原本没推出去、但兜底救回来了」摆出来 ----
327
+ var debugBody;
328
+ if (!audit) {
329
+ debugBody = muted(t.debugNone);
330
+ } else if (!audit.exists || audit.counts.retryOk + audit.counts.rescueOk === 0) {
331
+ debugBody = muted(t.debugEmpty);
332
+ } else {
333
+ debugBody = h(
334
+ 'div',
335
+ null,
336
+ h('div', { style: { fontSize: '12px', fontWeight: 600, marginBottom: '4px' } }, t.debugSummary(audit.counts)),
337
+ (audit.recent || []).map(function (item, index) {
338
+ return h(
339
+ 'div',
340
+ { key: String(index), style: { fontSize: '12px', lineHeight: '1.7', color: V.sub } },
341
+ '· ' + shortTime(item.at) + ' [' + (item.kind === 'retry-ok' ? t.kindRetry : t.kindRescue) + '] ' +
342
+ (item.label || '-') + ' — ' + item.detail
343
+ );
344
+ })
345
+ );
346
+ }
386
347
 
387
348
  return h(
388
349
  'div',
@@ -392,7 +353,6 @@ window.__ModuleLoader__.load({
392
353
  padding: '12px',
393
354
  border: '1px solid ' + V.border,
394
355
  borderRadius: '12px',
395
- color: V.label,
396
356
  fontSize: '13px',
397
357
  lineHeight: '1.6'
398
358
  }
@@ -400,47 +360,75 @@ window.__ModuleLoader__.load({
400
360
  h('div', { style: { fontWeight: 600, marginBottom: '10px' } }, t.section),
401
361
 
402
362
  // ---- 授权码 ----
403
- h('div', { style: { fontWeight: 600, marginBottom: '6px' } }, t.authTitle),
363
+ heading(t.authTitle),
364
+ row(
365
+ [
366
+ statusDot,
367
+ h(
368
+ 'span',
369
+ { style: { color: V.sub } },
370
+ (info && info.configured ? t.configured : t.missing) +
371
+ (info && info.configured && info.masked ? ' · ' + info.masked : '')
372
+ ),
373
+ info && info.sourceLabel
374
+ ? h('span', { style: { color: V.sub } }, '· ' + t.source + ': ' + info.sourceLabel)
375
+ : null
376
+ ],
377
+ { marginBottom: '8px' }
378
+ ),
404
379
  row([
405
- statusDot,
406
- h(
407
- 'span',
408
- { style: { color: V.sub } },
409
- (info && info.configured ? t.configured : t.missing) +
410
- (info && info.configured && info.masked ? ' · ' + info.masked : '')
411
- ),
412
- info && info.sourceLabel
413
- ? h('span', { style: { color: V.sub } }, '· ' + t.source + ': ' + info.sourceLabel)
414
- : null
415
- ], { marginBottom: '8px' }),
416
-
417
- row([input, btn(t.save, onSave, { primary: true }), btn(t.clear, onClear, { disabled: !(info && info.configured) })]),
380
+ input,
381
+ h(Button, { variant: 'primary', size: 'sm', onClick: onSave, disabled: busy === 'auth' }, t.save),
382
+ h(Button, { variant: 'outline', size: 'sm', onClick: onClear, disabled: busy === 'auth' || !(info && info.configured) }, t.clear)
383
+ ]),
418
384
  preview,
419
- h('div', { style: { marginTop: '8px', fontSize: '12px', color: V.sub } }, t.commandHint),
420
- h('div', { style: { marginTop: '4px', fontSize: '12px', color: V.sub } }, t.whereHint),
421
-
422
- note ? h('div', { style: { marginTop: '8px', fontSize: '12px', color: V.accent } }, note) : null,
385
+ muted(t.commandHint, { marginTop: '8px' }),
386
+ muted(t.whereHint, { marginTop: '4px' }),
387
+ note ? h('div', { style: { marginTop: '8px', fontSize: '12px' } }, note) : null,
423
388
  summary.error
424
- ? h('div', { style: { marginTop: '8px', fontSize: '12px', color: V.danger } },
425
- (summary.data ? t.saveFailed : t.loadFailed) + ': ' + summary.error)
389
+ ? h(
390
+ 'div',
391
+ { style: { marginTop: '8px', fontSize: '12px', color: V.danger } },
392
+ (summary.data ? t.saveFailed : t.loadFailed) + ': ' + summary.error
393
+ )
426
394
  : null,
427
395
 
428
- // ---- 兜底开关 ----
429
- h('div', {
430
- style: { marginTop: '14px', paddingTop: '12px', borderTop: '1px solid ' + V.border }
431
- },
396
+ // ---- 推送兜底 + 调试图 ----
397
+ h(
398
+ 'div',
399
+ { style: { marginTop: '14px', paddingTop: '12px', borderTop: '1px solid ' + V.border } },
432
400
  row([
433
401
  h('span', { style: { fontWeight: 600 } }, t.guardTitle),
434
402
  h('span', { style: { color: V.sub } }, guard && guard.enabled ? t.guardOn : t.guardOff),
435
- guardSwitch
403
+ h(Switch, {
404
+ checked: Boolean(guard && guard.enabled),
405
+ onChange: onToggleGuard,
406
+ label: t.guardTitle,
407
+ disabled: busy === 'guard' || !guard
408
+ })
436
409
  ]),
437
- h('div', { style: { marginTop: '6px', fontSize: '12px', color: V.sub } }, t.guardHint),
438
- guard
439
- ? h('div', { style: { marginTop: '4px', fontSize: '12px', color: V.sub } },
440
- (pickLang(localeRef) === 'zh'
441
- ? '重试预算:确定未送达 ' + guard.retryCount + ' 次 / 可能已送达 ' + guard.ambiguousRetryCount + ' 次;schedule_id 兜底:' + guard.defaultScheduleId
442
- : 'Retry budget: definitely-undelivered ' + guard.retryCount + ' / possibly-delivered ' + guard.ambiguousRetryCount + '; schedule_id fallback: ' + guard.defaultScheduleId))
443
- : null
410
+ muted(t.guardHint, { marginTop: '6px' }),
411
+ muted(t.guardWhat, { marginTop: '4px' }),
412
+ guard ? muted(t.guardBudget(guard), { marginTop: '4px' }) : null,
413
+
414
+ h(
415
+ 'div',
416
+ { style: { marginTop: '10px' } },
417
+ h(
418
+ 'div',
419
+ {
420
+ role: 'button',
421
+ tabIndex: 0,
422
+ onClick: function () { setShowDebug(!showDebug); },
423
+ onKeyDown: function (event) {
424
+ if (event.key === 'Enter' || event.key === ' ') setShowDebug(!showDebug);
425
+ },
426
+ style: { fontSize: '12px', fontWeight: 600, cursor: 'pointer', userSelect: 'none' }
427
+ },
428
+ (showDebug ? '▾ ' : '▸ ') + t.debugTitle
429
+ ),
430
+ showDebug ? h('div', { style: { marginTop: '4px' } }, debugBody) : null
431
+ )
444
432
  )
445
433
  );
446
434
  };
@@ -456,9 +444,7 @@ window.__ModuleLoader__.load({
456
444
  ctx.inject(['locale'], function (scope) {
457
445
  localeRef.current = scope.locale;
458
446
  if (typeof scope.effect === 'function') {
459
- scope.effect(function () {
460
- return function () { localeRef.current = null; };
461
- }, 'hiboard-push: locale handle');
447
+ scope.effect(function () { return function () { localeRef.current = null; }; }, 'hiboard-push: locale handle');
462
448
  }
463
449
  });
464
450
 
@@ -479,9 +465,16 @@ window.__ModuleLoader__.load({
479
465
  console.error('[hiboard-push] settings card skipped: ' + error);
480
466
  return;
481
467
  }
482
- var ConfigCard = Card(react, localeRef);
483
- // slots.inject 会等到插槽被声明为止,所以每个宿主只填它有的那个,
484
- // 另一个的等待永远不会触发。
468
+ var ui;
469
+ try {
470
+ ui = require('@deepseek-ai/dsh-client-ui-primitives');
471
+ } catch (error) {
472
+ // 拿不到原语就不渲染:宁可不显示,也不要退回手搓样式再错一次深浅色
473
+ console.error('[hiboard-push] settings card skipped (primitives unavailable): ' + error);
474
+ return;
475
+ }
476
+ var ConfigCard = Card(react, ui, localeRef);
477
+ // slots.inject 会等到插槽被声明为止,所以每个宿主只填它有的那个
485
478
  ctx.slots.inject('plugins.bundle.config', function* () {
486
479
  yield ctx.slots.register({ name: 'plugins.bundle.config', key: PACKAGE_NAME }, ConfigCard);
487
480
  });
@@ -499,7 +492,7 @@ window.__ModuleLoader__.load({
499
492
 
500
493
  exports.apply = apply;
501
494
  // 仅供仓库测试使用,不属于插件契约。
502
- exports.__card = { parseAuthCodeInput: parseAuthCodeInput, maskAuthCode: maskAuthCode, TEXT: TEXT };
495
+ exports.__card = { parseAuthCodeInput: parseAuthCodeInput, maskAuthCode: maskAuthCode, TEXT: TEXT, shortTime: shortTime };
503
496
  // slots 与 locale 都是可选的,各自走 scoped inject,所以这里不声明依赖。
504
497
  exports.inject = [];
505
498
  return module.exports;
package/src/lib/index.js CHANGED
@@ -28,6 +28,9 @@
28
28
  * Node built-in `fetch`.
29
29
  */
30
30
  import { randomUUID } from "node:crypto";
31
+ import { readFileSync } from "node:fs";
32
+ import { homedir } from "node:os";
33
+ import { join } from "node:path";
31
34
  import { defineTool } from "@deepseek-ai/dsh-tools";
32
35
  import z from "@deepseek-ai/schemastery";
33
36
  import { maskAuthCode, parseAuthCodeInput } from "./authcode.js";
@@ -62,6 +65,15 @@ const WIRE_SOURCE = "OpenClaw";
62
65
  /** Environment fallback for the authCode. */
63
66
  const AUTH_CODE_ENV = "DSH_HIBOARD_AUTH_CODE";
64
67
 
68
+ /**
69
+ * 兜底的审计/状态目录。**在这里算一次并显式传给兜底**,而不是两边各写一份默认值 ——
70
+ * 设置页要读同一个目录来统计「原本失败、但兜底救回来」的次数,路径必须唯一。
71
+ */
72
+ const GUARD_STATE_DIR = join(homedir(), ".dsh", "lan", "push-guard");
73
+
74
+ /** 调试图最多回溯多少条审计(文件本身有大小上限,读全量没必要)。 */
75
+ const AUDIT_MAX_LINES = 4000;
76
+
65
77
  /** Success codes accepted by the official client (string or numeric zero). */
66
78
  const SUCCESS_CODES = new Set(["0000000000", "0"]);
67
79
 
@@ -336,7 +348,7 @@ function hiboardPushTool({ resolveAuthCode, settings }) {
336
348
  const authCode = resolveAuthCode();
337
349
  if (!authCode) {
338
350
  throw new Error(
339
- "hiboard_push: 未配置授权码(authCode)。请在 dsh 的「插件」页打开本插件的设置卡片填写" +
351
+ "hiboard_push: 未配置授权码(authCode)。请打开 dsh 侧栏的「插件」面板 → 本插件 → 在「推送设置」卡片里填写" +
340
352
  "(支持整条关联指令或裸授权码),或写进 profile 补丁层 hiboard-push.config.authCode," +
341
353
  `或设置环境变量 ${AUTH_CODE_ENV}。授权码获取:负一屏 → 我的 → 动态管理 → 关联账号 → Claw 智能体。`
342
354
  );
@@ -376,7 +388,7 @@ function hiboardVerifyTool({ resolveAuthCode, settings }) {
376
388
  const authCode = resolveAuthCode();
377
389
  if (!authCode) {
378
390
  throw new Error(
379
- "hiboard_verify: 未配置授权码(authCode)。请在 dsh 的「插件」页打开本插件的设置卡片填写" +
391
+ "hiboard_verify: 未配置授权码(authCode)。请打开 dsh 侧栏的「插件」面板 → 本插件 → 在「推送设置」卡片里填写" +
380
392
  "(支持整条关联指令或裸授权码),或写进 profile 补丁层 hiboard-push.config.authCode," +
381
393
  `或设置环境变量 ${AUTH_CODE_ENV}。授权码获取:负一屏 → 我的 → 动态管理 → 关联账号 → Claw 智能体。`
382
394
  );
@@ -424,6 +436,63 @@ function buildConfigRoute({ authCodeInfo, settings, storeFile }) {
424
436
  env: `环境变量 ${AUTH_CODE_ENV}`,
425
437
  none: "未配置"
426
438
  };
439
+ /**
440
+ * 调试图的数据源:读兜底的审计流水,统计**「原本没推出去、但兜底救回来了」**的次数,
441
+ * 并列出最近几次。这正是兜底存在的意义 —— 不列出来,用户只能靠猜它到底干没干活。
442
+ *
443
+ * 两类算「救回来」:
444
+ * retry-ok 工具真的调用了但失败,按错误类别重试后成功(L3)
445
+ * rescue-ok 根本没调用工具(写成纯文本 / 只承诺没做),兜底代为补推(L1 / L2)
446
+ */
447
+ const auditSummary = () => {
448
+ const file = join(GUARD_STATE_DIR, "audit.jsonl");
449
+ const empty = { file, exists: false, counts: { retryOk: 0, rescueOk: 0, failed: 0 }, recent: [] };
450
+ let text;
451
+ try {
452
+ text = readFileSync(file, "utf8");
453
+ } catch {
454
+ // 还没跑过任何一轮:这是正常状态,不是错误
455
+ return empty;
456
+ }
457
+ const lines = text.split("\n").filter(Boolean).slice(-AUDIT_MAX_LINES);
458
+ const counts = { retryOk: 0, rescueOk: 0, failed: 0 };
459
+ const recent = [];
460
+ for (const line of lines) {
461
+ let row;
462
+ try {
463
+ row = JSON.parse(line);
464
+ } catch {
465
+ // 半行(正在写)或坏行:跳过。一条坏记录不该毁掉整块面板。
466
+ continue;
467
+ }
468
+ if (row?.kind === "retry-ok") {
469
+ counts.retryOk += 1;
470
+ recent.push({
471
+ // kind 必须带上:客户端靠它决定显示「重试」还是「补推」
472
+ kind: "retry-ok",
473
+ at: row.at ?? "",
474
+ level: "",
475
+ label: row.taskName ?? "",
476
+ detail: `首次失败后重试第 ${row.tries ?? "?"} 次成功`
477
+ });
478
+ } else if (row?.kind === "rescue-ok") {
479
+ counts.rescueOk += 1;
480
+ recent.push({
481
+ kind: "rescue-ok",
482
+ at: row.at ?? "",
483
+ level: row.level ?? "",
484
+ label: row.name ?? "",
485
+ detail: row.level === "L1" ? "模型把调用写成了纯文本" : "模型承诺了推送却没调用工具"
486
+ });
487
+ } else if (row?.kind === "push-failed" || row?.kind === "rescue-failed" || row?.kind === "rescue-error") {
488
+ counts.failed += 1;
489
+ }
490
+ }
491
+ // 文件是追加写的,末尾最新;面板要「最近几次」,所以倒序后取前几条
492
+ recent.reverse();
493
+ return { file, exists: true, counts, recent: recent.slice(0, 5) };
494
+ };
495
+
427
496
  const summary = () => {
428
497
  const info = authCodeInfo();
429
498
  const s = settings();
@@ -444,7 +513,9 @@ function buildConfigRoute({ authCodeInfo, settings, storeFile }) {
444
513
  defaultScheduleId: s.defaultScheduleId
445
514
  },
446
515
  endpoint: s.pushServiceUrl,
447
- storeFile
516
+ storeFile,
517
+ // 调试图:兜底到底把多少次「原本失败的推送」救了回来
518
+ audit: auditSummary()
448
519
  };
449
520
  };
450
521
 
@@ -599,6 +670,8 @@ function apply(ctx, rawConfig) {
599
670
  maxContentLength: settings().maxContentLength,
600
671
  defaultResult: settings().defaultResult,
601
672
  defaultScheduleId: settings().defaultScheduleId,
673
+ // 与设置页的调试图读同一个目录,路径只此一处定义
674
+ stateDir: GUARD_STATE_DIR,
602
675
  source: settings().source
603
676
  });
604
677