@mhfire/dsh-im-bridge 0.2.0 → 0.4.2

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
@@ -4,10 +4,35 @@
4
4
 
5
5
  WeCom AI Bot ⇄ DeepSeek Harness Agent bridge — a **DSH plugin**.
6
6
 
7
- Creates Agents **in-process** inside a dsh profile (no child-process spawn): per-sender durable sessions (the same WeCom user reuses one session with memory), sessions registered with the Web GUI (live view and continue-chat), plus a Settings → Plugins card (`botId` / `secret`, allow-list, timeouts, copy, and model overrides, written to `settings.yaml`).
7
+ Creates Agents **in-process** inside a dsh profile (no child-process spawn): one durable DSH session **per WeCom chat window** (1:1 = that user; everyone in the same group shares one session a person’s DM and group chats stay separate), sessions registered with the Web GUI (live view and continue-chat), plus a Settings → Plugins card (`botId` / `secret`, allow-list, timeouts, copy, and model overrides, written to `settings.yaml`).
8
8
 
9
9
  The Host half registers the `im-bridge` namespace through `installSettingsSection`. The browser half contributes a card into `settings.plugin.item` under `key: im-bridge`. The card can set `botId` / `secret` on the same user layer as the profile patch. Live fields such as `startHint` apply on the next message; changing credentials still requires a process restart to open the WebSocket.
10
10
 
11
+ ## Session granularity
12
+
13
+ One DSH session maps to one WeCom chat window, not “every window of the same userid”:
14
+
15
+ - **1:1**: `single:<userid>` — one Agent for that user
16
+ - **Group**: `group:<chatid>` — all members share one Agent and one serial queue (two people speaking at once still cannot concurrent-`followup`)
17
+ - `allowFrom` filters **who may chat** by sender userid; empty = enqueue everyone. Office commands use `wecomCli.allowFrom` separately
18
+ - Leading `@nickname` mentions are stripped on arrival, so both the model input and the title read `测试一下` instead of `@MediaAgent 测试一下`. Mentions later in the text stay, and a message that is nothing but mentions goes to the model unchanged
19
+ - Process restart continues the same durable session via a stable `wecom-` + short hash of the key: adopt a live Agent if the process already has one (for example the browser already opened that row), `resume` from persistence otherwise, and `create` only when neither exists. Resume cwd / preset follow the archive, not the current config; a cwd mismatch logs a warning and is not rewritten.
20
+ - The GUI title is `企微·私聊` / `企微·群` plus the first user prompt (the same automatic title as other sessions), not a userid / chatid; two windows of the same kind with similar first lines stay two sidebar rows
21
+ - Archiving a WeCom session in the GUI ends that context: the next message silently starts a new session at `wecom-<hash>-2` (`-3` after another archive), with no extra WeCom notice. DSH has no unarchive API, so the archived session is merely no longer written to — it neither becomes visible again nor is deleted
22
+ - Every window still shares the same `workspace` (files / rag). That is environment isolation, separate from chat-context windows
23
+
24
+ ## Compatible DeepSeek Harness versions
25
+
26
+ DeepSeek Harness is still a developer preview and makes **no semver compatibility promise** to out-of-tree plugins. Package 0.4.2 is aligned to published tags by the APIs it actually calls:
27
+
28
+ | DSH | This package |
29
+ |---|---|
30
+ | [0.1.0-rc.8](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.0-rc.8), [0.1.1-rc.1](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.1-rc.1), [0.1.1-rc.2](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.1-rc.2) | Compatible (developed against the 0.1.1-rc.2 line) |
31
+ | [0.1.0-rc.7](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.0-rc.7) and earlier | Not compatible. rc.7 already has the plugin settings-card slot and `dsh plugin add`, but the browser has no `settingsScope.describe()`, so the card and the credential “Configured” badges fail |
32
+ | Newer RCs / untagged HEAD | Not guaranteed. After upgrading dsh, re-check the Settings card and the WeCom connection |
33
+
34
+ Pin `dsh` to `0.1.0-rc.8` or later, for example `npx @deepseek-ai/dsh@0.1.1-rc.2 web`. Do not rely on a floating `latest`.
35
+
11
36
  ## Install
12
37
 
13
38
  ### Recommended: npm package
@@ -15,7 +40,7 @@ The Host half registers the `im-bridge` namespace through `installSettingsSectio
15
40
  ```powershell
16
41
  dsh plugin --profile web add @mhfire/dsh-im-bridge
17
42
  # Or pin a version:
18
- # dsh plugin --profile web add @mhfire/dsh-im-bridge@0.2.0
43
+ # dsh plugin --profile web add @mhfire/dsh-im-bridge@0.4.2
19
44
  ```
20
45
 
21
46
  In `$DSH_HOME/profiles/web/cordis.patch.yml` (or your profile), supply credentials only (other fields ship as bundle defaults and can be overridden):
@@ -38,7 +63,25 @@ Install from this repo’s `plugin/` directory or a `file:` path:
38
63
  dsh plugin --profile web add <package-path>
39
64
  ```
40
65
 
41
- If `botId` / `secret` are missing, the plugin still loads (does not block `dsh web`); logs warn that WeCom connect is skipped. You can also fill them at the top of the Settings Plugins card (same user layer as the profile patch); the badge becomes “Configured” after save. Changing credentials still requires a **restart** to connect (this release does not hot-reconnect).
66
+ If `botId` / `secret` are missing, the plugin still loads (does not block `dsh web`); logs warn that WeCom connect is skipped. You can also fill them on the Settings plugin card (see the next section).
67
+
68
+ ## Settings plugin card
69
+
70
+ After the plugin is installed and `dsh web` is running, open **Settings → Plugins → Plugin configuration** and expand **WeCom Bridge** (same card chrome as Shell / Agent loop / Web search). **Save** writes the user layer of `settings.yaml`, the same layer as the profile `cordis.patch.yml`. **Discard** drops unsaved drafts. Fields marked **Overridden** can be **Reset** to the bundle default.
71
+
72
+ Fields, top to bottom:
73
+
74
+ | Card control | Config key | After save |
75
+ |---|---|---|
76
+ | Bot ID / Secret | `botId` / `secret` | Badge becomes “Configured”; a **process restart** is required to open the WebSocket. Inputs are password fields; stored literals never ride the wire. A **blank save does not clear** a stored credential |
77
+ | Allowed sender userids | `allowFrom` | Applies on the next message; comma-separated, empty = allow everyone |
78
+ | Task timeout (seconds) | `agentTimeoutSec` | Applies on the next message |
79
+ | Placeholder while thinking | `startHint` | Applies on the next message |
80
+ | Denied-sender reply | `deniedMessage` | Applies on the next message |
81
+ | Welcome message | `welcomeMessage` | Applies on the next message |
82
+ | WeCom-only provider / model | `provider` / `model` | Applies only to **later new** WeCom-window sessions; both must be set to override, otherwise the GUI default model is used |
83
+
84
+ `workspace`, `agentPreset`, `persona` / `personaFile`, `thinking`, `maxReplyBytes`, `reasoningEffort`, and `wecomCli` are not on the card; set them in the profile patch or the table below. This release does not hot-reconnect credentials; changing `wecomCli` also requires a process restart.
42
85
 
43
86
  ## Configuration
44
87
 
@@ -48,16 +91,17 @@ The bundle `cordis.patch.yml` supplies defaults for every field except `botId` /
48
91
  |---|---|
49
92
  | `botId` / `secret` | WeCom AI Bot credentials (`role('secret')`, redacted in UI); when empty, WeCom side is skipped and the host keeps running |
50
93
  | `workspace` | Agent working directory (session cwd) |
51
- | `allowFrom` | Allowed sender userids; empty = allow everyone |
94
+ | `allowFrom` | Chat allow-list; empty = everyone may ask. Does not gate wecom-cli |
52
95
  | `agentTimeoutSec` | Max seconds per task; also drives progress / ETA |
53
96
  | `startHint` | Placeholder text when processing starts |
54
97
  | `agentPreset` | Agent preset to mount (default `standard`) |
55
- | `provider` / `model` | WeCom-only model; **both must be non-empty** to override, otherwise follow the GUI `agent-default-model`. Filling only one warns and falls back. Editable in Settings; applies to later new sender sessions only |
98
+ | `provider` / `model` | WeCom-only model; **both must be non-empty** to override, otherwise follow the GUI `agent-default-model`. Filling only one warns and falls back. Editable in Settings; applies to later new WeCom-window sessions only |
56
99
  | `reasoningEffort` | Optional effort when the WeCom override is in effect; ignored otherwise |
57
100
  | `persona` / `personaFile` | Bot persona; precedence: `personaFile` → `persona` → packaged default (zh/en via Host `locale.preference`); overrides do not follow language; do not commit secrets |
58
101
  | `maxReplyBytes` | Reply size cap in bytes (default 20000) |
59
102
  | `deniedMessage` | Reply when the sender is not on `allowFrom` (editable in Settings) |
60
103
  | `welcomeMessage` | Welcome text on `enter_chat` (editable in Settings) |
104
+ | `wecomCli` | Optional WeCom office skills (off by default). See the next section |
61
105
  | `thinking` | Streaming animation. Precedence: tool activity (`toolLabels`) > model stream phase (`reasoningStatus` / `outputStatus` from `assistant/chunk`) > timed `phases` fallback; also `spin` / `reasoningSpin` / `outputSpin` / `eggs` |
62
106
 
63
107
  To give WeCom a different model from the GUI, set both in the profile `cordis.patch.yml`:
@@ -87,6 +131,41 @@ thinking:
87
131
  pwsh: PowerShell
88
132
  ```
89
133
 
134
+ ## WeCom office skills (wecom-cli)
135
+
136
+ The plugin depends on the official [`@wecom/cli`](https://www.npmjs.com/package/@wecom/cli) binary. Install `wecomcli-*` under **`$DSH_HOME/wecom-cli-skills`** (not workspace `.dsh/skills` / `.agents/skills`, and not `$DSH_HOME/skills`). Only the **office userid’s 1:1** agent gets the office layer: `skills.register()` for the catalog and `tools.register()` for the gated `wecom_cli` tool. Both go through that agent’s own ctx, so group chats and the GUI never see them. Other workspace skills stay on `skill-filesystem`.
137
+
138
+ Office commands run **only through the `wecom_cli` tool**: the model passes `argv` (the arguments after `wecom-cli`), the plugin spawns the official binary directly, and any `auth init` is refused. The `wecom-cli` on PATH is a shim that prints a refusal and exits 1, so group chats, the GUI, and any `pwsh wecom-cli` fail; the shim’s message points back at `wecom_cli`. The credential directory is never exported to the process environment — it is injected per spawn.
139
+
140
+ `wecomCli.enabled` is off by default. Turning it on requires a non-empty **`wecomCli.allowFrom`** (office userids). Root `allowFrom` only gates who may chat; empty means everyone may ask. An empty office list logs a warning and skips the shim / auth.
141
+
142
+ One-time setup:
143
+
144
+ 1. Leave root `allowFrom` empty (everyone may ask) and put **office** userids in `wecomCli.allowFrom` (do not leave that empty)
145
+ 2. In **Settings → Plugins → WeCom Bridge**, click **Install official skills** (the Host downloads the official repo zip into `$DSH_HOME/wecom-cli-skills`). **Do not** use `npx skills add -g` (it leaks into the GUI). The skills CLI has **no `--dir`**, so that flag does not write into the plugin directory. If `wecomcli-*` folders already sit in the workspace, move them here and delete the workspace copies. You can also copy official `skills/wecomcli-*` into `$DSH_HOME/wecom-cli-skills` by hand.
146
+
147
+ 3. Enable it in the profile `cordis.patch.yml` (the plugin then writes wecom-cli credentials from the existing `botId` / `secret` via hidden `auth init --bot-id/--secret`; no QR scan and no `npm install -g @wecom/cli`):
148
+
149
+ ```yaml
150
+ - id: im-bridge
151
+ config:
152
+ allowFrom: []
153
+ wecomCli:
154
+ enabled: true
155
+ allowFrom: ["<office userid>"]
156
+ # skillsDir: '' # empty = $DSH_HOME/wecom-cli-skills
157
+ # configDir: '' # empty = <workspace>/.dsh/wecom-cli; gitignore this directory
158
+ ```
159
+
160
+ | Field | Description |
161
+ |---|---|
162
+ | `wecomCli.enabled` | Install the PATH deny shim, run the auth check, and wire the prompt plus the `wecom_cli` tool; default `false` |
163
+ | `wecomCli.allowFrom` | Userids that receive the `wecom_cli` tool; empty skips the shim / auth. Independent of the chat list |
164
+ | `wecomCli.skillsDir` | Override skills root; empty = `$DSH_HOME/wecom-cli-skills` |
165
+ | `wecomCli.configDir` | Override the credential directory; empty = `<workspace>/.dsh/wecom-cli`. Gitignore that directory. `WECOM_CLI_CONFIG_DIR` is injected only when the plugin spawns the CLI, never into the process environment, so `~/.config/wecom` stays unused. |
166
+
167
+ Changing `wecomCli` requires a process restart. Messages still arrive when unauthorized; at boot the plugin seeds credentials with hidden `--bot-id/--secret` (stderr is not a TTY). If automatic seeding fails, the log prints a manual command that sets `WECOM_CLI_CONFIG_DIR` — keep that prefix, or `npx --yes @wecom/cli auth init --manual` writes to `~/.config/wecom`, which the plugin never reads. Do not run `auth init` from the agent (that creates a new bot).
168
+
90
169
  ## Persona
91
170
 
92
171
  Precedence: `personaFile` → `persona` string → packaged default persona.
@@ -104,11 +183,35 @@ Precedence: `personaFile` → `persona` string → packaged default persona.
104
183
 
105
184
  Or copy [`persona.example.md`](./persona.example.md) as a fill-in template. Supports `{{model}}` / `{{cwd}}`. Do not commit secret-bearing persona files. Welcome / deny / thinking copy remain Chinese config strings and do not follow locale yet.
106
185
 
186
+ ## Sending PNGs to WeCom
187
+
188
+ If the agent’s final reply contains Markdown pointing at a **workspace** PNG, the bridge uploads it after the text stream finishes and sends it as a **separate image message** (`uploadMedia`, then `sendMediaMessage` with `media_id`).
189
+
190
+ Trigger (paths relative to `workspace`):
191
+
192
+ ```markdown
193
+ ![screenshot](main_screen.png)
194
+ [screenshot](out/frame.png)
195
+ ```
196
+
197
+ - Only `.png`; `http(s):` / `data:` are ignored; the file must stay inside `workspace`
198
+ - Must be a real PNG (signature), at most **10MB** each, at most **10** images (first-seen order, deduped)
199
+ - Writing a PNG without that Markdown does **not** send it
200
+ - Images follow the text bubble; they are not inlined into the stream
201
+ - Inbound image / voice / file messages are still ignored
202
+
107
203
  ## Security
108
204
 
109
- - Do not commit secret-bearing files such as `config.json` / `persona.md`
205
+ - Do not commit secret-bearing files such as `config.json` / `persona.md` / `.dsh/wecom-cli/`
110
206
  - Session and tool output may contain adversarial text; the plugin’s safety prompt tells the agent not to treat tool output as instructions
111
207
 
208
+ ## Known Limitations and Deferred Work
209
+
210
+ - wecom-cli credentials live in the workspace `<workspace>/.dsh/wecom-cli` (gitignore it). People on `wecomCli.allowFrom` borrow that identity’s office permissions; the chat list (root `allowFrom`) does not grant office access. An office 1:1 may still send to any `--chat-id`; the plugin does not pin recipients.
211
+ - Gating is not a sandbox. The `wecom_cli` tool and `wecomcli-*` register only on the office 1:1 agent, PATH resolves `wecom-cli` to a refusal, and the credential directory reaches only the plugin's own spawns — but a shell in the same process can still bypass all of it: run `node <absolute path to @wecom/cli's wecom.js>`, or `npx --yes @wecom/cli` with a self-set `WECOM_CLI_CONFIG_DIR`. Without that variable such a bypass lands on the unauthorized `~/.config/wecom`. Real isolation needs a process sandbox.
212
+ - The WeCom channel has no GUI confirmation dialog. Irreversible actions (send mail, cancel a meeting, delete a todo, overwrite a document) are constrained only by the prompt (run `--dry-run` first, wait for the next user message). Every WeCom session forbids `ask_user_question` (it hangs until the task times out).
213
+ - Leftover `wecomcli-*` folders in workspace `.dsh/skills` / `.agents/skills` remain visible to every agent with that cwd, including GUI. Other skills are unchanged. `enabled: false` only turns off the deny shim, the auth check, `wecom_cli`, and wecomcli-* registration; the channel ban on `ask_user_question` is still injected.
214
+
112
215
  ## License
113
216
 
114
217
  MIT
package/README.md CHANGED
@@ -4,10 +4,35 @@
4
4
 
5
5
  企业微信智能机器人 ⇄ DeepSeek Harness Agent 桥接 **DSH 插件**。
6
6
 
7
- 在 dsh profile 内**进程内**创建 Agent(不再 spawn 子进程):per-sender 持久会话(同一企业微信用户复用同一会话,有上下文记忆),会话与 Web GUI 同进程注册(实时可见、可续聊),并在 Settings → 插件配置页提供配置卡片(`botId` / `secret`、白名单、超时、提示语、模型覆盖,写入 `settings.yaml`)。
7
+ 在 dsh profile 内**进程内**创建 Agent(不再 spawn 子进程):按**企微窗口**拆分持久会话(单聊 = 该用户一条;同一群里所有人共用一条;同一个人的私聊和群聊互不串上下文),会话与 Web GUI 同进程注册(实时可见、可续聊),并在 Settings → 插件配置页提供配置卡片(`botId` / `secret`、白名单、超时、提示语、模型覆盖,写入 `settings.yaml`)。
8
8
 
9
9
  Host 通过 `installSettingsSection` 注册 `im-bridge` 命名空间;浏览器半包以 `key: im-bridge` 挂进 `settings.plugin.item`。卡片可填 `botId` / `secret`,与 profile patch 写入同一用户层;改 `startHint` 等热字段后下一轮消息即生效,改凭证仍需重启进程才会连 WebSocket。
10
10
 
11
+ ## 会话粒度
12
+
13
+ 一条 DSH 会话对应一个企微聊天窗口,而不是「同一个 userid 的所有窗口」:
14
+
15
+ - **单聊**:`single:<userid>`,该用户一条 Agent
16
+ - **群聊**:`group:<chatid>`,群内所有人共用一条 Agent 和同一条串行队列(两人同时发也不会并发 `followup`)
17
+ - `allowFrom` 按**发送者** userid 拦截谁能聊天;空 = 所有人可进队。办公命令另用 `wecomCli.allowFrom`
18
+ - 群里 @机器人 的消息,开头的 `@昵称` 在入站就去掉:模型看到的和标题用的都是「测试一下」而不是「@MediaAgent 测试一下」;正文中间的 @某人 保留,整条只有 @ 时按原文交给模型
19
+ - 进程重启后用稳定 id `wecom-` + key 的短 hash 续上同一条会话:进程里已有活 Agent 就直接采用(例如浏览器已打开该行),存档里有就 `resume`,都没有才 `create`。resume 的 cwd / preset 跟存档,不跟当前配置;cwd 不一致时打警告,不改写。
20
+ - GUI 标题为「企微·私聊/群」+ 第一句用户话(与其它会话一样由 DSH 生成),不再露出 userid / chatid;同类型窗口若第一句话相近,侧栏仍是两行
21
+ - 在 GUI 里归档某条企微会话 = 结束那段上下文:下一条消息用 `wecom-<hash>-2`(再归档就 `-3`)静默开一条新会话,企微侧不额外提示。DSH 没有取消归档的接口,所以旧会话只是不再被本插件写入,既不会恢复可见也不会被删除
22
+ - 所有窗口仍共用同一个 `workspace`(文件 / rag 环境),与聊天上下文分窗是两件事
23
+
24
+ ## 兼容的 DeepSeek Harness 版本
25
+
26
+ DeepSeek Harness 仍是 developer preview,对外置插件**没有 semver 兼容承诺**。本包 0.4.2 按实际调用的 API 对齐已发布 tag:
27
+
28
+ | DSH | 本包 |
29
+ |---|---|
30
+ | [0.1.0-rc.8](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.0-rc.8)、[0.1.1-rc.1](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.1-rc.1)、[0.1.1-rc.2](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.1-rc.2) | 可适配(对照开发的是 0.1.1-rc.2 一线) |
31
+ | [0.1.0-rc.7](https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.0-rc.7) 及更早 | 不可适配。rc.7 已有插件配置卡槽位和 `dsh plugin add`,但浏览器没有 `settingsScope.describe()`,配置卡加载和凭证「已配置」徽章会失败 |
32
+ | 更新的 RC / 未打 tag 的 HEAD | 未保证。升级 dsh 后请再验 Settings 卡和企微连线 |
33
+
34
+ 建议把 `dsh` 钉在 `0.1.0-rc.8` 及以上,例如 `npx @deepseek-ai/dsh@0.1.1-rc.2 web`,不要只跑浮动的 `latest`。
35
+
11
36
  ## 安装
12
37
 
13
38
  ### 推荐:从 npm 安装
@@ -15,7 +40,7 @@ Host 通过 `installSettingsSection` 注册 `im-bridge` 命名空间;浏览器
15
40
  ```powershell
16
41
  dsh plugin --profile web add @mhfire/dsh-im-bridge
17
42
  # 或钉版本:
18
- # dsh plugin --profile web add @mhfire/dsh-im-bridge@0.2.0
43
+ # dsh plugin --profile web add @mhfire/dsh-im-bridge@0.4.2
19
44
  ```
20
45
 
21
46
  在 `$DSH_HOME/profiles/web/cordis.patch.yml`(或对应 profile)中补密钥即可(其余项已有 bundle 默认,可按需覆盖):
@@ -38,7 +63,25 @@ dsh plugin --profile web add @mhfire/dsh-im-bridge
38
63
  dsh plugin --profile web add <本包路径>
39
64
  ```
40
65
 
41
- 未配置 `botId` / `secret` 时插件仍会加载(不阻塞 `dsh web`),日志会提示跳过企微连线;也可在 Settings → 插件配置卡片顶部填写,保存后徽章变为「已配置」,与 profile patch 同一用户层。改凭证仍要**重启**进程才会连接(本版本不做热重连)。
66
+ 未配置 `botId` / `secret` 时插件仍会加载(不阻塞 `dsh web`),日志会提示跳过企微连线;也可在 Settings 插件配置卡填写(见下节)。
67
+
68
+ ## Settings 插件配置卡
69
+
70
+ 安装插件并启动 `dsh web` 后,打开 **设置 → 插件 → 插件配置**,展开 **企业微信桥接**(与「终端 / Agent 循环 / 网页搜索」同一组卡片)。改完点右下角 **保存** 写入 `settings.yaml` 用户层,与 profile `cordis.patch.yml` 同一层;**放弃** 丢掉未保存草稿。带「已覆盖」的字段可 **重置** 回 bundle 默认。
71
+
72
+ 卡片字段自上而下:
73
+
74
+ | 卡片项 | 对应配置 | 保存后 |
75
+ |---|---|---|
76
+ | Bot ID / Secret | `botId` / `secret` | 徽章变为「已配置」;**须重启进程** 才会连 WebSocket。输入框为密码框,线上看不到已存字面值;**留空再保存不会清空**已存凭证 |
77
+ | 允许的发送者 userid | `allowFrom` | 下一轮消息生效;逗号分隔,空 = 允许所有人 |
78
+ | 单任务超时(秒) | `agentTimeoutSec` | 下一轮消息生效 |
79
+ | 开始处理时的占位提示 | `startHint` | 下一轮消息生效 |
80
+ | 非白名单拒绝文案 | `deniedMessage` | 下一轮消息生效 |
81
+ | 进入会话欢迎语 | `welcomeMessage` | 下一轮消息生效 |
82
+ | 企微专用 provider / model | `provider` / `model` | 只影响之后**新建**的企微窗口会话;须两项都填才覆盖,否则跟随 GUI 默认模型 |
83
+
84
+ `workspace`、`agentPreset`、`persona` / `personaFile`、`thinking`、`maxReplyBytes`、`reasoningEffort`、`wecomCli` 不在卡片上,仍在 profile patch 或下表中配置。本版本不做凭证热重连;改 `wecomCli` 也须重启进程。
42
85
 
43
86
  ## 配置项
44
87
 
@@ -48,16 +91,17 @@ bundle 的 `cordis.patch.yml` 已为除 `botId` / `secret` 外的字段提供默
48
91
  |---|---|
49
92
  | `botId` / `secret` | 企业微信智能机器人凭证(`role('secret')`,UI 自动脱敏);缺省时跳过企微侧,不阻塞主进程 |
50
93
  | `workspace` | Agent 工作目录(会话 cwd) |
51
- | `allowFrom` | 允许的发送者 userid 白名单;空 = 允许所有人 |
94
+ | `allowFrom` | 聊天白名单;空 = 允许所有人问诊断。不控制 wecom-cli |
52
95
  | `agentTimeoutSec` | 单任务最长执行时间(秒),动画进度条/剩余估算的基准 |
53
96
  | `startHint` | 开始处理时的占位提示语 |
54
97
  | `agentPreset` | Agent 加入的 preset(默认 `standard`) |
55
- | `provider` / `model` | 企微专用模型;**两者都非空**才覆盖,否则跟随 GUI 的 `agent-default-model`;只填一项会告警并回退。Settings 可编,只影响之后新建的发送者会话 |
98
+ | `provider` / `model` | 企微专用模型;**两者都非空**才覆盖,否则跟随 GUI 的 `agent-default-model`;只填一项会告警并回退。Settings 可编,只影响之后新建的企微窗口会话 |
56
99
  | `reasoningEffort` | 覆盖生效时可选的推理强度;未覆盖模型时忽略 |
57
100
  | `persona` / `personaFile` | 机器人「人设」;优先级:`personaFile` → `persona` → 包内默认(按 Host `locale.preference` 选中/英);覆盖不跟语言切换;含敏感信息请勿入库 |
58
101
  | `maxReplyBytes` | 回复上限(字节,默认 20000) |
59
102
  | `deniedMessage` | 非白名单用户的拒绝文案(Settings 可编) |
60
103
  | `welcomeMessage` | 进入会话欢迎语(Settings 可编) |
104
+ | `wecomCli` | 可选的企业微信办公能力(默认关闭)。见下一节 |
61
105
  | `thinking` | 流式动画。优先级:工具活动(`toolLabels`)> 模型流式阶段(`reasoningStatus` / `outputStatus`,来自 `assistant/chunk`)> 时间轴 `phases` 兜底;另有 `spin` / `reasoningSpin` / `outputSpin` / `eggs` 等 |
62
106
 
63
107
  企微与 GUI 使用不同模型时,在 profile `cordis.patch.yml` 同时填写:
@@ -87,6 +131,41 @@ thinking:
87
131
  pwsh: PowerShell
88
132
  ```
89
133
 
134
+ ## 企业微信办公能力(wecom-cli)
135
+
136
+ 插件依赖官方 [`@wecom/cli`](https://www.npmjs.com/package/@wecom/cli) 二进制。`wecomcli-*` 装在 **`$DSH_HOME/wecom-cli-skills`**(不要装进工作区 `.dsh/skills` / `.agents/skills`,也不要装进 `$DSH_HOME/skills`)。插件只在**办公 userid 的单聊** Agent 上注入:`skills.register()` 装 catalog,`tools.register()` 装门控工具 `wecom_cli`。两者都走该 Agent 自己的 ctx,群聊与 GUI 看不到。工作区里其它 skill 仍由 `skill-filesystem` 发现,不受影响。
137
+
138
+ 办公命令**只经 `wecom_cli` 工具执行**:模型传 `argv`(`wecom-cli` 之后的参数数组),插件直接 spawn 官方二进制,并拒绝任何 `auth init`。PATH 上的 `wecom-cli` 是一个只打印拒绝信息并 `exit 1` 的 shim,所以群聊、GUI 以及任何 `pwsh wecom-cli` 都跑不通;shim 的文案会指回 `wecom_cli`。凭证目录不进程级导出,只在插件自己 spawn 时注入。
139
+
140
+ `wecomCli.enabled` 默认关闭。开启须同时配置非空 **`wecomCli.allowFrom`**(办公 userid);根级 `allowFrom` 只管谁能聊天,空名单表示所有人可问诊断。办公名单为空时插件会告警并跳过 shim / 授权 / `wecom_cli`。
141
+
142
+ 一次性准备:
143
+
144
+ 1. 根级 `allowFrom` 留空(所有人可问诊断),把 **办公** userid 写进 `wecomCli.allowFrom`(不要留空)
145
+ 2. 在 **Settings → 插件配置 → 企业微信桥接** 点「安装官方 skills」(Host 下载官方仓库 zip,解到 `$DSH_HOME/wecom-cli-skills`)。**不要用** `npx skills add -g`(会泄漏给 GUI);skills CLI **没有 `--dir`**,加了也不会写到程序目录。若工作区里已有 `wecomcli-*`,先挪到该目录再删工作区副本。也可手动把官方仓库 `skills/wecomcli-*` 拷进 `$DSH_HOME/wecom-cli-skills`。
146
+
147
+ 3. 在 profile `cordis.patch.yml` 打开(启用后插件会用已有 `botId` / `secret` 走 `auth init --bot-id/--secret` 写入 wecom-cli 凭据,不必扫码、不必 `npm install -g @wecom/cli`):
148
+
149
+ ```yaml
150
+ - id: im-bridge
151
+ config:
152
+ allowFrom: []
153
+ wecomCli:
154
+ enabled: true
155
+ allowFrom: ["<办公 userid>"]
156
+ # skillsDir: '' # 空 = $DSH_HOME/wecom-cli-skills
157
+ # configDir: '' # 空 = <workspace>/.dsh/wecom-cli;请 gitignore
158
+ ```
159
+
160
+ | 字段 | 说明 |
161
+ |---|---|
162
+ | `wecomCli.enabled` | 装 PATH 拒绝 shim、跑授权检查、给企微 Agent 接上 prompt 与 `wecom_cli` 工具;默认 `false` |
163
+ | `wecomCli.allowFrom` | 能拿到 `wecom_cli` 工具的 userid;空则跳过 shim / 授权。与根级聊天名单独立 |
164
+ | `wecomCli.skillsDir` | 覆盖 skills 根目录;空 = `$DSH_HOME/wecom-cli-skills` |
165
+ | `wecomCli.configDir` | 覆盖凭证目录;空 = `<workspace>/.dsh/wecom-cli`。请把该目录加入 gitignore。`WECOM_CLI_CONFIG_DIR` 只在插件 spawn CLI 时注入,不写进程环境,因此不会使用 `~/.config/wecom`。 |
166
+
167
+ 改 `wecomCli` 后须重启进程。未授权时插件仍收消息;启动时用隐藏的 `--bot-id/--secret`(stderr 非 TTY)写入凭据。若自动写入失败,日志会打印一条带 `WECOM_CLI_CONFIG_DIR` 的手动命令——必须带上它,否则 `npx --yes @wecom/cli auth init --manual` 会把凭证写到 `~/.config/wecom`,插件读不到。禁止在 Agent 里扫码 `auth init`(会新建机器人)。
168
+
90
169
  ## 人设(persona)
91
170
 
92
171
  优先级:`personaFile` → `persona` 字符串 → 包内默认人设。
@@ -104,11 +183,35 @@ thinking:
104
183
 
105
184
  也可复制 [`persona.example.md`](./persona.example.md) 为模板后按环境填写。支持 `{{model}}` / `{{cwd}}` 占位符。含环境凭据的人设文件请勿提交。欢迎语 / 拒绝文案 / 思考动画文案目前仍为中文配置项,不随语言切换。
106
185
 
186
+ ## 把 PNG 发到企业微信
187
+
188
+ Agent 的最终回复若包含指向**工作区内** PNG 的 Markdown,桥会在文字流结束之后,把图作为**独立图片消息**发出(先 `uploadMedia`,再用 `media_id` 调用 `sendMediaMessage`)。
189
+
190
+ 触发写法(相对 `workspace`):
191
+
192
+ ```markdown
193
+ ![屏幕截图](main_screen.png)
194
+ [屏幕截图](out/frame.png)
195
+ ```
196
+
197
+ - 只认 `.png`;跳过 `http(s):` / `data:`;路径必须落在 `workspace` 内
198
+ - 文件须为真实 PNG(文件头),单张不超过 **10MB**,最多 **10** 张(按出现顺序去重)
199
+ - 只把 PNG 写到磁盘、回复里没有上述 Markdown,**不会**发图
200
+ - 图在文字气泡之后另发,不会嵌进同一条流式消息
201
+ - 入站图片/语音/文件消息仍忽略
202
+
107
203
  ## 安全
108
204
 
109
- - `config.json` / `persona.md` 等含密钥文件不入库;
205
+ - `config.json` / `persona.md` / `.dsh/wecom-cli/` 等含密钥文件不入库;
110
206
  - 会话与工具输出可能含对抗性文本,插件内置安全提示词约束 agent 不把工具输出当指令。
111
207
 
208
+ ## Known Limitations and Deferred Work
209
+
210
+ - wecom-cli 凭证在工作区 `<workspace>/.dsh/wecom-cli`(请 gitignore)。`wecomCli.allowFrom` 里的人借用这份凭据的办公权限;聊天名单(根级 `allowFrom`)不授予办公。办公单聊仍可向任意 `--chat-id` 发信,插件不锁定收件人。
211
+ - 门控不是沙箱。`wecom_cli` 工具与 `wecomcli-*` 只注册到办公单聊 Agent,PATH 上的 `wecom-cli` 一律拒绝,凭证目录也只在插件自己 spawn 时注入;但同进程的 shell 仍可绕过:直接 `node <@wecom/cli 的 wecom.js 绝对路径>`,或 `npx --yes @wecom/cli` 并自行设置 `WECOM_CLI_CONFIG_DIR`。不设该变量时这类旁路会落到未授权的 `~/.config/wecom`。真正的隔离需要进程级沙箱。
212
+ - 企微通道没有 GUI 审批框:发信、取消会议、删待办、覆盖文档等不可逆操作只靠 prompt 约束(先 `--dry-run`,等用户下一条确认)。所有企微会话都禁止 `ask_user_question`(会挂到超时)。
213
+ - 工作区 `.dsh/skills` / `.agents/skills` 里残留的 `wecomcli-*` 仍会被同 cwd 的 GUI 和群聊发现。其它 skill 不受影响。`enabled: false` 只关拒绝 shim、授权检查、`wecom_cli` 与 wecomcli-* 注册;通道上的 `ask_user_question` 禁令仍会注入。
214
+
112
215
  ## License
113
216
 
114
217
  MIT
package/cordis.patch.yml CHANGED
@@ -18,6 +18,12 @@
18
18
  maxReplyBytes: 20000
19
19
  deniedMessage: '无权访问本服务'
20
20
  welcomeMessage: '👋 办公助手已就绪。直接发消息即可,例如查文件、整理文档、查资料或处理日常事务。'
21
+ wecomCli:
22
+ enabled: false
23
+ skillsDir: ''
24
+ allowFrom: []
25
+ # skillsDir 空 = $DSH_HOME/wecom-cli-skills(不要用工作区 .dsh/skills)
26
+ # configDir: '' # 空 = <workspace>/.dsh/wecom-cli
21
27
  thinking:
22
28
  eggAfterSec: 240
23
29
  intervalMs: 1500
package/lib/client.js CHANGED
@@ -115,7 +115,7 @@ window.__ModuleLoader__.load({
115
115
  }
116
116
  //#endregion
117
117
  //#region \0dsh-css:C:\Users\user\Desktop\dsh-im-bridge\plugin\src\client\fields.module.css.mjs
118
- const css = ".OMD7cq_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.OMD7cq_field+.OMD7cq_field{border-top:1px solid var(--dsw-alias-border-l2)}.OMD7cq_head{align-items:center;gap:8px;display:flex}.OMD7cq_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.OMD7cq_badges{align-items:center;gap:8px;display:inline-flex}.OMD7cq_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.OMD7cq_badgeMuted{white-space:nowrap;color:var(--dsw-alias-label-tertiary);border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px}.OMD7cq_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}.OMD7cq_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}.OMD7cq_reset:disabled{cursor:default}.OMD7cq_input{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.OMD7cq_input:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}.OMD7cq_input:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.OMD7cq_inputInvalid{border:1px solid var(--dsw-alias-label-error);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.OMD7cq_inputInvalid:focus-visible{outline:none}.OMD7cq_invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}.OMD7cq_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}";
118
+ const css = ".OMD7cq_field{flex-direction:column;gap:6px;padding:12px 0;display:flex}.OMD7cq_field+.OMD7cq_field{border-top:1px solid var(--dsw-alias-border-l2)}.OMD7cq_head{align-items:center;gap:8px;display:flex}.OMD7cq_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}.OMD7cq_badges{align-items:center;gap:8px;display:inline-flex}.OMD7cq_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}.OMD7cq_badgeMuted{white-space:nowrap;color:var(--dsw-alias-label-tertiary);border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px}.OMD7cq_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}.OMD7cq_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}.OMD7cq_reset:disabled{cursor:default}.OMD7cq_input{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.OMD7cq_input:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}.OMD7cq_input:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}.OMD7cq_input::placeholder{color:var(--dsw-alias-label-tertiary)}.OMD7cq_inputInvalid{border:1px solid var(--dsw-alias-label-error);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}.OMD7cq_inputInvalid:focus-visible{outline:none}.OMD7cq_invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}.OMD7cq_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}.OMD7cq_install{appearance:none;font:inherit;cursor:pointer;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border:1px solid #0000;border-radius:8px;align-self:flex-start;padding:5px 14px;font-size:13px;line-height:1.5}.OMD7cq_install:hover:not(:disabled){opacity:.92}.OMD7cq_install:disabled{opacity:.4;cursor:default}.OMD7cq_install:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}";
119
119
  const tagId = "@mhfire/dsh-im-bridge/fields.module.css";
120
120
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
121
121
  const tag = document.createElement("style");
@@ -133,6 +133,7 @@ window.__ModuleLoader__.load({
133
133
  "hint": "OMD7cq_hint",
134
134
  "input": "OMD7cq_input",
135
135
  "inputInvalid": "OMD7cq_inputInvalid",
136
+ "install": "OMD7cq_install",
136
137
  "invalid": "OMD7cq_invalid",
137
138
  "label": "OMD7cq_label",
138
139
  "reset": "OMD7cq_reset"
@@ -189,7 +190,8 @@ window.__ModuleLoader__.load({
189
190
  }
190
191
  /**
191
192
  * Write-only credential control. The literal never rides a response, so the
192
- * control starts blank and reports only whether one is configured.
193
+ * control starts blank and reports only whether one is configured. A configured
194
+ * empty draft shows a dots placeholder so the box does not look unset.
193
195
  * @param props - the field's copy, staged text, and configured state.
194
196
  * @returns the labelled control.
195
197
  */
@@ -217,6 +219,7 @@ window.__ModuleLoader__.load({
217
219
  type: "password",
218
220
  autoComplete: "off",
219
221
  value: props.text,
222
+ placeholder: props.configured && props.text === "" ? "••••••••" : void 0,
220
223
  disabled: props.disabled,
221
224
  onChange: (event) => {
222
225
  props.onEdit(event.target.value);
@@ -278,6 +281,16 @@ window.__ModuleLoader__.load({
278
281
  props.edit("secret", text);
279
282
  }
280
283
  }),
284
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SkillsInstall, {
285
+ t,
286
+ disabled,
287
+ available: state.skillsInstallAvailable,
288
+ status: state.skillsInstallStatus,
289
+ dest: state.skillsDest,
290
+ count: state.skillsCount,
291
+ error: state.skillsError,
292
+ onInstall: props.installSkills
293
+ }),
281
294
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ValueField, {
282
295
  id: "im-bridge-allowFrom",
283
296
  label: t("allowFrom"),
@@ -373,6 +386,44 @@ window.__ModuleLoader__.load({
373
386
  ]
374
387
  });
375
388
  }
389
+ function skillsStatusText(t, status, dest, count, error, available) {
390
+ if (!available) return t("skillsUnavailable");
391
+ if (status === "ok") return t("skillsInstalled").replace("{count}", String(count)).replace("{dest}", dest);
392
+ if (status === "error") return error === "" ? t("skillsFailed") : `${t("skillsFailed")} ${error}`;
393
+ return t("skillsHint");
394
+ }
395
+ /**
396
+ * Host-side install of official wecomcli-* (browser never chooses the path).
397
+ * @param props - copy, status, and the install action.
398
+ * @returns the labelled control.
399
+ */
400
+ function SkillsInstall(props) {
401
+ const installing = props.status === "installing";
402
+ const status = skillsStatusText(props.t, props.status, props.dest, props.count, props.error, props.available);
403
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
404
+ className: fields_module_css_default.field,
405
+ children: [
406
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
407
+ className: fields_module_css_default.head,
408
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
409
+ className: fields_module_css_default.label,
410
+ children: props.t("skillsTitle")
411
+ })
412
+ }),
413
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
414
+ type: "button",
415
+ className: fields_module_css_default.install,
416
+ disabled: props.disabled || !props.available || installing,
417
+ onClick: props.onInstall,
418
+ children: props.t(installing ? "skillsInstalling" : "skillsInstall")
419
+ }),
420
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
421
+ className: props.status === "error" || !props.available ? fields_module_css_default.invalid : fields_module_css_default.hint,
422
+ children: status
423
+ })
424
+ ]
425
+ });
426
+ }
376
427
  //#endregion
377
428
  //#region src/client/card-form.ts
378
429
  /**
@@ -577,6 +628,10 @@ window.__ModuleLoader__.load({
577
628
  this.failed = false;
578
629
  this.publish();
579
630
  }
631
+ /** Rebuild bound projections for card-owned state outside the settings draft. */
632
+ notify() {
633
+ this.publish();
634
+ }
580
635
  spec(field) {
581
636
  const spec = this.specs.get(field);
582
637
  if (spec === void 0) throw new Error(`im-bridge card has no field ${field}`);
@@ -610,15 +665,22 @@ window.__ModuleLoader__.load({
610
665
  var WecomCardController = class {
611
666
  scope;
612
667
  describe;
668
+ rpc;
613
669
  form;
614
670
  store;
671
+ skillsInstallStatus = "idle";
672
+ skillsDest = "";
673
+ skillsCount = 0;
674
+ skillsError = "";
615
675
  /**
616
676
  * @param scope - bound settings scope for the `im-bridge` namespace.
617
677
  * @param describe - Host describe face; secret literals never ride it.
678
+ * @param rpc - optional Connection RPC for the skills install button.
618
679
  */
619
- constructor(scope, describe) {
680
+ constructor(scope, describe, rpc) {
620
681
  this.scope = scope;
621
682
  this.describe = describe;
683
+ this.rpc = rpc;
622
684
  this.form = new CardForm(scope, [
623
685
  csvField("allowFrom"),
624
686
  numberField("agentTimeoutSec"),
@@ -649,7 +711,12 @@ window.__ModuleLoader__.load({
649
711
  deniedMessage: this.form.field("deniedMessage"),
650
712
  welcomeMessage: this.form.field("welcomeMessage"),
651
713
  provider: this.form.field("provider"),
652
- model: this.form.field("model")
714
+ model: this.form.field("model"),
715
+ skillsInstallAvailable: this.rpc !== void 0,
716
+ skillsInstallStatus: this.skillsInstallStatus,
717
+ skillsDest: this.skillsDest,
718
+ skillsCount: this.skillsCount,
719
+ skillsError: this.skillsError
653
720
  };
654
721
  }
655
722
  /**
@@ -670,11 +737,45 @@ window.__ModuleLoader__.load({
670
737
  await this.scope.set(field, text);
671
738
  return this.secretConfigured(field);
672
739
  }
740
+ /**
741
+ * Download official wecomcli-* into the Host-resolved directory.
742
+ * The browser does not choose the path.
743
+ */
744
+ installSkills() {
745
+ this.runInstall();
746
+ }
747
+ async runInstall() {
748
+ if (this.rpc === void 0 || this.skillsInstallStatus === "installing") return;
749
+ this.skillsInstallStatus = "installing";
750
+ this.skillsError = "";
751
+ this.form.notify();
752
+ try {
753
+ const result = await this.rpc.call("/im-bridge", "wecomcli.installSkills", {});
754
+ if (!result.ok) {
755
+ this.skillsInstallStatus = "error";
756
+ this.skillsError = result.error.message;
757
+ this.form.notify();
758
+ return;
759
+ }
760
+ const value = result.value;
761
+ this.skillsInstallStatus = "ok";
762
+ this.skillsDest = typeof value.dest === "string" ? value.dest : "";
763
+ this.skillsCount = typeof value.count === "number" ? value.count : 0;
764
+ this.skillsError = "";
765
+ } catch (error) {
766
+ this.skillsInstallStatus = "error";
767
+ this.skillsError = error instanceof Error ? error.message : String(error);
768
+ }
769
+ this.form.notify();
770
+ }
673
771
  /** Face the slot registration injects. */
674
772
  inject() {
675
773
  return {
676
774
  hooks: { wecomCard: this.store },
677
- ...this.form.actions()
775
+ ...this.form.actions(),
776
+ installSkills: () => {
777
+ this.installSkills();
778
+ }
678
779
  };
679
780
  }
680
781
  };
@@ -697,7 +798,7 @@ window.__ModuleLoader__.load({
697
798
  invalidNumber: "Enter a finite number.",
698
799
  botId: "Bot ID",
699
800
  secret: "Secret",
700
- secretHint: "Leave blank to keep the stored value. Save, then restart the process to open the WebSocket.",
801
+ secretHint: "The box stays empty on purpose (the stored value never rides the wire). A Configured badge means it is saved; type a new value to replace it. Save, then restart the process to open the WebSocket.",
701
802
  secretConfigured: "Configured",
702
803
  secretUnset: "Not configured",
703
804
  allowFrom: "Allowed sender userids",
@@ -713,7 +814,14 @@ window.__ModuleLoader__.load({
713
814
  provider: "WeCom-only provider",
714
815
  providerHint: "Empty follows the GUI default model. Both provider and model must be set to override.",
715
816
  model: "WeCom-only model",
716
- modelHint: "Takes effect only together with provider."
817
+ modelHint: "Takes effect only together with provider.",
818
+ skillsTitle: "WeCom office skills",
819
+ skillsHint: "Installs wecomcli-* into the plugin directory ($DSH_HOME/wecom-cli-skills). Do not use npx skills add -g; that CLI has no --dir. An empty Bot ID / Secret box is normal.",
820
+ skillsInstall: "Install official skills",
821
+ skillsInstalling: "Installing…",
822
+ skillsInstalled: "Installed {count} skills into {dest}",
823
+ skillsFailed: "Install failed.",
824
+ skillsUnavailable: "Install needs the Web host Connection."
717
825
  };
718
826
  /** Chinese copy for the im-bridge card. */
719
827
  const zh = {
@@ -732,7 +840,7 @@ window.__ModuleLoader__.load({
732
840
  invalidNumber: "请输入有效数字。",
733
841
  botId: "Bot ID",
734
842
  secret: "Secret",
735
- secretHint: "留空保留已存值。保存后需重启进程才会连 WebSocket。",
843
+ secretHint: "框空是正常的(已存值不会传到浏览器)。徽章「已配置」即已保存;重新输入可覆盖。保存后需重启进程才会连 WebSocket。",
736
844
  secretConfigured: "已配置",
737
845
  secretUnset: "未配置",
738
846
  allowFrom: "允许的发送者 userid",
@@ -748,7 +856,14 @@ window.__ModuleLoader__.load({
748
856
  provider: "企微专用 provider",
749
857
  providerHint: "空 = 跟随 GUI 默认模型。须与 model 同时填写才覆盖。",
750
858
  model: "企微专用 model",
751
- modelHint: "仅在同时填写 provider 时生效。"
859
+ modelHint: "仅在同时填写 provider 时生效。",
860
+ skillsTitle: "企微办公 skills",
861
+ skillsHint: "装到程序目录 $DSH_HOME/wecom-cli-skills。不要用 npx skills add -g;CLI 没有 --dir。Bot ID / Secret 框空是正常的。",
862
+ skillsInstall: "安装官方 skills",
863
+ skillsInstalling: "正在安装…",
864
+ skillsInstalled: "已装 {count} 个到 {dest}",
865
+ skillsFailed: "安装失败。",
866
+ skillsUnavailable: "安装需要 Web Host 的 Connection。"
752
867
  };
753
868
  //#endregion
754
869
  //#region src/client/index.ts
@@ -767,7 +882,8 @@ window.__ModuleLoader__.load({
767
882
  * @param ctx - browser plugin context.
768
883
  */
769
884
  function apply(ctx) {
770
- const card = new WecomCardController(ctx.settingsScope.bind({ namespace: NS }), ctx.settingsScope.describe());
885
+ const connection = ctx.get("connection");
886
+ const card = new WecomCardController(ctx.settingsScope.bind({ namespace: NS }), ctx.settingsScope.describe(), connection?.rpc);
771
887
  ctx.effect(() => ctx.locale.register(NS, {
772
888
  zh,
773
889
  en