@mhfire/dsh-im-bridge 0.1.1 → 0.1.3

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 ADDED
@@ -0,0 +1,82 @@
1
+ [中文](./README.md) | English
2
+
3
+ # @mhfire/dsh-im-bridge
4
+
5
+ WeCom AI Bot ⇄ DeepSeek Harness Agent bridge — a **DSH plugin**.
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 (`allowFrom` / `agentTimeoutSec` / `startHint`, written to `settings.yaml` with hot reload).
8
+
9
+ ## Install
10
+
11
+ ### Recommended: npm package
12
+
13
+ ```powershell
14
+ dsh plugin --profile web add @mhfire/dsh-im-bridge
15
+ # Or pin a version:
16
+ # dsh plugin --profile web add @mhfire/dsh-im-bridge@0.1.2
17
+ ```
18
+
19
+ In `$DSH_HOME/profiles/web/cordis.patch.yml` (or your profile), supply credentials only (other fields ship as bundle defaults and can be overridden):
20
+
21
+ ```yaml
22
+ - id: im-bridge
23
+ config:
24
+ botId: "<your BotID>"
25
+ secret: "<your Secret>"
26
+ # optional: workspace / personaFile / … — see Configuration below
27
+ ```
28
+
29
+ Restart dsh to apply (e.g. `dsh web` / `pnpm dsh web`).
30
+
31
+ ### Optional: local development
32
+
33
+ Install from this repo’s `plugin/` directory or a `file:` path:
34
+
35
+ ```powershell
36
+ dsh plugin --profile web add <package-path>
37
+ ```
38
+
39
+ If `botId` / `secret` are missing, the plugin still loads (does not block `dsh web`); logs warn that WeCom connect is skipped. You can fill credentials in Settings → Plugins; **restart** is required to connect (this release does not hot-start the WebSocket).
40
+
41
+ ## Configuration
42
+
43
+ The bundle `cordis.patch.yml` supplies defaults for every field except `botId` / `secret`. Full field list:
44
+
45
+ | Field | Description |
46
+ |---|---|
47
+ | `botId` / `secret` | WeCom AI Bot credentials (`role('secret')`, redacted in UI); when empty, WeCom side is skipped and the host keeps running |
48
+ | `workspace` | Agent working directory (session cwd) |
49
+ | `allowFrom` | Allowed sender userids; empty = allow everyone |
50
+ | `agentTimeoutSec` | Max seconds per task; also drives progress / ETA |
51
+ | `startHint` | Placeholder text when processing starts |
52
+ | `agentPreset` | Agent preset to mount (default `standard`) |
53
+ | `persona` / `personaFile` | Bot persona (system prompt); `personaFile` wins; do not commit secrets |
54
+ | `maxReplyBytes` | Reply size cap in bytes (default 20000) |
55
+ | `deniedMessage` | Reply when the sender is not on `allowFrom` (editable in Settings) |
56
+ | `welcomeMessage` | Welcome text on `enter_chat` (editable in Settings) |
57
+ | `thinking` | Streaming “thinking” animation: `phases` / `spin` / `eggs` / `eggAfterSec` / `intervalMs` / `activityPrefix`; override via profile patch (not the simple Settings card) |
58
+
59
+ Example `thinking.phases` override:
60
+
61
+ ```yaml
62
+ thinking:
63
+ intervalMs: 1500
64
+ phases:
65
+ - atSec: 0
66
+ text: '🤔 Understanding your request…'
67
+ ```
68
+
69
+ ## Persona
70
+
71
+ `persona.md` is the bot persona: role and behavior rules, injected as a system prompt each session.
72
+ Copy `persona.example.md` to `persona.md` and edit; supports `{{model}}` / `{{cwd}}`.
73
+ The file may contain credentials and is gitignored — do not commit it.
74
+
75
+ ## Security
76
+
77
+ - Do not commit secret-bearing files such as `config.json` / `persona.md`
78
+ - Session and tool output may contain adversarial text; the plugin’s safety prompt tells the agent not to treat tool output as instructions
79
+
80
+ ## License
81
+
82
+ MIT
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ 中文 | [English](./README.en.md)
2
+
1
3
  # @mhfire/dsh-im-bridge
2
4
 
3
5
  企业微信智能机器人 ⇄ DeepSeek Harness Agent 桥接 **DSH 插件**。
@@ -6,28 +8,43 @@
6
8
 
7
9
  ## 安装
8
10
 
11
+ ### 推荐:从 npm 安装
12
+
9
13
  ```powershell
10
- dsh plugin --profile web add <本包路径>
14
+ dsh plugin --profile web add @mhfire/dsh-im-bridge
15
+ # 或钉版本:
16
+ # dsh plugin --profile web add @mhfire/dsh-im-bridge@0.1.2
11
17
  ```
12
18
 
13
- profile 的 `cordis.patch.yml` 中配置:
19
+ `$DSH_HOME/profiles/web/cordis.patch.yml`(或对应 profile)中补密钥即可(其余项已有 bundle 默认,可按需覆盖):
14
20
 
15
21
  ```yaml
16
22
  - id: im-bridge
17
23
  config:
18
24
  botId: "<你的 BotID>"
19
25
  secret: "<你的 Secret>"
20
- workspace: "<Agent 工作目录>"
21
- personaFile: "<绝对路径>/persona.md"
26
+ # 可选:workspace / personaFile 等,见下方配置项
22
27
  ```
23
28
 
24
- 重启 dsh 进程即可使用。
29
+ 重启 dsh 进程即可使用(例如 `dsh web` / `pnpm dsh web`)。
30
+
31
+ ### 备选:本地开发
32
+
33
+ 从本仓库 `plugin/` 目录或 `file:` 路径安装:
34
+
35
+ ```powershell
36
+ dsh plugin --profile web add <本包路径>
37
+ ```
38
+
39
+ 未配置 `botId` / `secret` 时插件仍会加载(不阻塞 `dsh web`),日志会提示跳过企微连线;Settings → 插件配置页仍可填写,保存后**重启**进程才会连接(本版本不做热启连)。
25
40
 
26
41
  ## 配置项
27
42
 
43
+ bundle 的 `cordis.patch.yml` 已为除 `botId` / `secret` 外的字段提供默认值;下表为完整说明。
44
+
28
45
  | 字段 | 说明 |
29
46
  |---|---|
30
- | `botId` / `secret` | 企业微信智能机器人凭证(`role('secret')`,UI 自动脱敏) |
47
+ | `botId` / `secret` | 企业微信智能机器人凭证(`role('secret')`,UI 自动脱敏);缺省时跳过企微侧,不阻塞主进程 |
31
48
  | `workspace` | Agent 工作目录(会话 cwd) |
32
49
  | `allowFrom` | 允许的发送者 userid 白名单;空 = 允许所有人 |
33
50
  | `agentTimeoutSec` | 单任务最长执行时间(秒),动画进度条/剩余估算的基准 |
@@ -35,6 +52,19 @@ dsh plugin --profile web add <本包路径>
35
52
  | `agentPreset` | Agent 加入的 preset(默认 `standard`) |
36
53
  | `persona` / `personaFile` | 机器人「人设」(系统提示词);`personaFile` 优先,含敏感信息请勿入库 |
37
54
  | `maxReplyBytes` | 回复上限(字节,默认 20000) |
55
+ | `deniedMessage` | 非白名单用户的拒绝文案(Settings 可编) |
56
+ | `welcomeMessage` | 进入会话欢迎语(Settings 可编) |
57
+ | `thinking` | 流式思考动画:`phases` / `spin` / `eggs` / `eggAfterSec` / `intervalMs` / `activityPrefix`;结构请在 profile patch 覆盖,简易卡片不编辑 |
58
+
59
+ `thinking.phases` 示例(profile 覆盖时可只改文案):
60
+
61
+ ```yaml
62
+ thinking:
63
+ intervalMs: 1500
64
+ phases:
65
+ - atSec: 0
66
+ text: '🤔 正在理解你的需求…'
67
+ ```
38
68
 
39
69
  ## 人设(persona)
40
70
 
package/cordis.patch.yml CHANGED
@@ -1,6 +1,47 @@
1
- # dsh-im-bridge bundle patch: 声明插件行。
2
- # 密钥(botId/secret)等敏感配置由 profile 层($DSH_HOME/profiles/web/cordis.patch.yml)覆盖,
3
- # 不要写在这里。
1
+ # dsh-im-bridge bundle patch: 插入插件行并给出非密钥默认配置。
2
+ # botId/secret profile 层 ($DSH_HOME/profiles/<name>/cordis.patch.yml) 或 Settings 覆盖。
4
3
  - insert:
5
4
  - id: im-bridge
6
5
  name: '@mhfire/dsh-im-bridge'
6
+ config:
7
+ workspace: !!js process.cwd()
8
+ allowFrom: []
9
+ startHint: '🧠 正在思考...'
10
+ agentTimeoutSec: 600
11
+ agentPreset: standard
12
+ persona: ''
13
+ personaFile: ''
14
+ maxReplyBytes: 20000
15
+ deniedMessage: '无权访问本服务'
16
+ welcomeMessage: '👋 办公助手已就绪。直接发消息即可,例如查文件、整理文档、查资料或处理日常事务。'
17
+ thinking:
18
+ eggAfterSec: 240
19
+ intervalMs: 1500
20
+ activityPrefix: '🛠️ 正在执行 '
21
+ spin:
22
+ - '🧠'
23
+ - '💭'
24
+ - '✨'
25
+ - '🔎'
26
+ - '⚡'
27
+ eggs:
28
+ - '📎 顺手把要点整理好了,稍后一起给你'
29
+ - '📶 网络有点忙,让它慢慢跑'
30
+ - '🎯 结果快出来了,坚持一下'
31
+ - '🗂️ 资料较多,正在汇总中'
32
+ - '🌙 别盯着了,完成会自动通知你'
33
+ phases:
34
+ - atSec: 0
35
+ text: '🤔 正在理解你的需求…'
36
+ - atSec: 8
37
+ text: '📋 正在整理任务清单'
38
+ - atSec: 25
39
+ text: '🔍 正在查找相关资料'
40
+ - atSec: 55
41
+ text: '✍️ 正在处理文档/数据'
42
+ - atSec: 120
43
+ text: '🧠 正在思考最佳方案…'
44
+ - atSec: 240
45
+ text: '⏳ 任务较繁琐,请稍候…'
46
+ - atSec: 420
47
+ text: '☕ 快好了,正在收尾…'
package/lib/client.js CHANGED
@@ -18,6 +18,8 @@ var FIELDS = [
18
18
  { field: 'allowFrom', label: '允许的发送者 userid(逗号分隔,空 = 所有人)', kind: 'text' },
19
19
  { field: 'agentTimeoutSec', label: '单任务超时(秒)', kind: 'number' },
20
20
  { field: 'startHint', label: '开始处理时的占位提示', kind: 'text' },
21
+ { field: 'deniedMessage', label: '非白名单拒绝文案', kind: 'text' },
22
+ { field: 'welcomeMessage', label: '进入会话欢迎语', kind: 'text' },
21
23
  ];
22
24
 
23
25
  function fmt(f, v) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mhfire/dsh-im-bridge",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "企业微信智能机器人 ⇄ DeepSeek Harness Agent 桥接插件:进程内创建 Agent(per-sender 持久会话),会话在 GUI 实时可见;含 Settings 插件配置卡片",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,7 +19,8 @@
19
19
  "src",
20
20
  "lib",
21
21
  "cordis.patch.yml",
22
- "persona.example.md"
22
+ "persona.example.md",
23
+ "README.en.md"
23
24
  ],
24
25
  "dsh": {
25
26
  "bundle": {
package/src/index.js CHANGED
@@ -19,7 +19,7 @@ import z from '@deepseek-ai/schemastery'
19
19
  import { createUserMessage } from '@deepseek-ai/dsh-llm'
20
20
  import { SessionId } from '@deepseek-ai/dsh-session'
21
21
  import { installModelSelection } from '@deepseek-ai/dsh-agent'
22
- import { startThinking, sendFinal, truncate, footerOf, fmtDuration } from './wecom.js'
22
+ import { startThinking, sendFinal, truncate, footerOf, fmtDuration, DEFAULT_THINKING } from './wecom.js'
23
23
 
24
24
  /** 稳定插件名 */
25
25
  export const name = 'im-bridge'
@@ -27,10 +27,24 @@ export const name = 'im-bridge'
27
27
  /** 依赖的核心服务 */
28
28
  export const inject = ['agents', 'sessions', 'agentDefaultModel']
29
29
 
30
- /** 插件配置(zod): 敏感项由 profile 层 patch 提供 */
30
+ const ThinkingPhase = z.object({
31
+ atSec: z.number(),
32
+ text: z.string(),
33
+ })
34
+
35
+ const ThinkingConfig = z.object({
36
+ phases: z.array(ThinkingPhase).default(DEFAULT_THINKING.phases),
37
+ spin: z.array(String).default(DEFAULT_THINKING.spin),
38
+ eggs: z.array(String).default(DEFAULT_THINKING.eggs),
39
+ eggAfterSec: z.number().default(DEFAULT_THINKING.eggAfterSec),
40
+ intervalMs: z.number().default(DEFAULT_THINKING.intervalMs),
41
+ activityPrefix: z.string().default(DEFAULT_THINKING.activityPrefix),
42
+ })
43
+
44
+ /** 插件配置(zod): 敏感项由 profile 层 patch 或 Settings 提供;缺省时跳过企微连线,不阻塞主进程 */
31
45
  export const Config = z.object({
32
- botId: z.string().required().role('secret'),
33
- secret: z.string().required().role('secret'),
46
+ botId: z.string().default('').role('secret'),
47
+ secret: z.string().default('').role('secret'),
34
48
  /** Agent 的工作目录(会话 cwd) */
35
49
  workspace: z.string().default(process.cwd()),
36
50
  /** 允许的发送者 userid 白名单; 空 = 允许所有人 */
@@ -47,6 +61,12 @@ export const Config = z.object({
47
61
  personaFile: z.string().default(''),
48
62
  /** 回复上限(字节) */
49
63
  maxReplyBytes: z.number().default(20000),
64
+ /** 流式思考动画素材(阶段/表情/彩蛋等); 可在 profile patch 覆盖 */
65
+ thinking: ThinkingConfig.default(DEFAULT_THINKING),
66
+ /** 非白名单用户的拒绝文案 */
67
+ deniedMessage: z.string().default('无权访问本服务'),
68
+ /** 用户进入会话时的欢迎语 */
69
+ welcomeMessage: z.string().default('👋 办公助手已就绪。直接发消息即可,例如查文件、整理文档、查资料或处理日常事务。'),
50
70
  })
51
71
 
52
72
  /** 收集一次 turn 内最后一条 assistant 文本与结束原因 */
@@ -90,14 +110,11 @@ export function apply(ctx, config) {
90
110
  throw new Error('im-bridge: 需要 agents/sessions/agentDefaultModel 服务')
91
111
  }
92
112
 
93
- // per-sender 持久会话状态: sender -> { agent, sessionId, queue, lastActivity }
94
- const senders = new Map()
95
- const persona = resolvePersona(config)
96
-
97
113
  // ── 设置命名空间: 用户可在 GUI 插件配置页 / settings.yaml 覆盖字段(热生效) ──
98
114
  // 规范做法(与 @deepseek-ai/dsh-settings 的 installSettingsSection 一致):
99
115
  // 用 ctx.inject(['settings'], cb) 延迟到 settings 服务可用时再注册,
100
116
  // 避免 apply 时 settings 尚未挂载导致命名空间缺失(GUI 卡片显示"命名空间不可用")。
117
+ // 缺凭证时仍注册, 便于在 Settings 补齐后再重启启用企微。
101
118
  let scope
102
119
  ctx.inject(['settings'], (sctx) => {
103
120
  scope = sctx.settings.register('im-bridge', Config, { base: { ...config } })
@@ -105,66 +122,83 @@ export function apply(ctx, config) {
105
122
  /** 有效配置: 默认值 → cordis patch(base) → 用户 settings.yaml, 热更新 */
106
123
  const cfg = () => scope?.value ?? config
107
124
 
108
- /** 创建(或复用)某发送者的 Agent(会话/上下文持久于进程内) */
109
- async function ensureAgent(sender) {
110
- let st = senders.get(sender)
111
- if (st !== undefined && st.agent !== undefined) return st
112
- const sessionId = SessionId(`session-${randomUUID()}`)
113
- const selection = defaultModel.currentSelection()
114
- // preset roster 的部署(如 web profile)必须在 setup mount,
115
- // 否则 agent 看不到任何工具(模型只能编造工具调用)
116
- const presets = ctx.get('agentPresets')
117
- let resolvedId = cfg().agentPreset
118
- if (presets !== undefined) {
119
- resolvedId = (await presets.resolve(cfg().agentPreset)).id
125
+ // ── 企微 WebSocket: 延迟到 loader settle 后启动, 不占启动关键路径 ──
126
+ void (async () => {
127
+ await ctx.get('loader')?.await()
128
+ const { botId, secret } = cfg()
129
+ if (!botId || !secret) {
130
+ console.warn(
131
+ '[im-bridge] 跳过启动: 缺少 botId/secret。请在 profile cordis.patch.yml Settings 插件配置中填写后重启。',
132
+ )
133
+ return
120
134
  }
121
- const { agent } = await agents.create({
122
- sessionId,
123
- meta: { cwd: cfg().workspace, agentPreset: resolvedId },
124
- agentOptions: { provider: selection.provider, model: selection.model },
125
- setup: async (agentCtx) => {
126
- const selected = { current: selection, assembled: undefined }
127
- installModelSelection(agentCtx, selected)
128
- if (presets !== undefined) await presets.mount(agentCtx, resolvedId)
129
- if (persona !== '') {
130
- agentCtx.inject(['systemPrompt'], (promptCtx) => {
131
- promptCtx.systemPrompt.section({
132
- name: 'deployment:persona', // 同名 scoped section 覆盖部署 persona(仅本 agent)
133
- order: 0,
134
- text: persona,
135
- })
136
- })
137
- }
138
- },
139
- })
140
- st = { agent, sessionId, queue: Promise.resolve(), lastActivity: '' }
141
- senders.set(sender, st)
142
- console.log(`[im-bridge] 为 ${sender} 创建会话 ${sessionId}`)
143
- return st
144
- }
145
135
 
146
- // 会话事件 真实活动状态(工具调用名), 让动画显示"正在做什么"
147
- ctx.on('session/event', (session, event) => {
148
- if (event.type !== 'tool/call') return
149
- for (const st of senders.values()) {
150
- if (st.sessionId === session.id) st.lastActivity = `🛠️ 正在执行 ${event.data.name}`
136
+ // per-sender 持久会话状态: sender -> { agent, sessionId, queue, lastActivity }
137
+ const senders = new Map()
138
+ const persona = resolvePersona(cfg())
139
+
140
+ /** 创建(或复用)某发送者的 Agent(会话/上下文持久于进程内) */
141
+ async function ensureAgent(sender) {
142
+ let st = senders.get(sender)
143
+ if (st !== undefined && st.agent !== undefined) return st
144
+ const sessionId = SessionId(`session-${randomUUID()}`)
145
+ const selection = defaultModel.currentSelection()
146
+ // 有 preset roster 的部署(如 web profile)必须在 setup 里 mount,
147
+ // 否则 agent 看不到任何工具(模型只能编造工具调用)。
148
+ const presets = ctx.get('agentPresets')
149
+ let resolvedId = cfg().agentPreset
150
+ if (presets !== undefined) {
151
+ resolvedId = (await presets.resolve(cfg().agentPreset)).id
152
+ }
153
+ const { agent } = await agents.create({
154
+ sessionId,
155
+ meta: { cwd: cfg().workspace, agentPreset: resolvedId },
156
+ agentOptions: { provider: selection.provider, model: selection.model },
157
+ setup: async (agentCtx) => {
158
+ const selected = { current: selection, assembled: undefined }
159
+ installModelSelection(agentCtx, selected)
160
+ if (presets !== undefined) await presets.mount(agentCtx, resolvedId)
161
+ if (persona !== '') {
162
+ agentCtx.inject(['systemPrompt'], (promptCtx) => {
163
+ promptCtx.systemPrompt.section({
164
+ name: 'deployment:persona', // 同名 scoped section 覆盖部署 persona(仅本 agent)
165
+ order: 0,
166
+ text: persona,
167
+ })
168
+ })
169
+ }
170
+ },
171
+ })
172
+ st = { agent, sessionId, queue: Promise.resolve(), lastActivity: '' }
173
+ senders.set(sender, st)
174
+ console.log(`[im-bridge] 为 ${sender} 创建会话 ${sessionId}`)
175
+ return st
151
176
  }
152
- })
153
177
 
154
- // ── 企微 WebSocket: 延迟到 loader settle 后启动, 不占启动关键路径 ──
155
- void (async () => {
156
- await ctx.get('loader')?.await()
157
- const { default: AiBot } = await import('@wecom/aibot-node-sdk')
178
+ // 会话事件 真实活动状态(工具调用名), 让动画显示"正在做什么"
179
+ ctx.on('session/event', (session, event) => {
180
+ if (event.type !== 'tool/call') return
181
+ const prefix = cfg().thinking?.activityPrefix ?? DEFAULT_THINKING.activityPrefix
182
+ for (const st of senders.values()) {
183
+ if (st.sessionId === session.id) st.lastActivity = `${prefix}${event.data.name}`
184
+ }
185
+ })
186
+
187
+ const { default: AiBot, generateReqId } = await import('@wecom/aibot-node-sdk')
158
188
 
159
189
  /** 处理一条消息: 动画 → followup → 汇总 → 简报 */
160
190
  async function handle(frame, sender, content) {
161
191
  const st = await ensureAgent(sender)
162
192
  const startedAt = Date.now()
163
- const streamId = AiBot.generateReqId('stream')
193
+ const streamId = generateReqId('stream')
164
194
  let stopThinking = null
165
195
  try {
166
196
  await ws.replyStream(frame, streamId, cfg().startHint, false)
167
- stopThinking = startThinking(ws, frame, streamId, startedAt, cfg().agentTimeoutSec, () => st.lastActivity || '')
197
+ stopThinking = startThinking(
198
+ ws, frame, streamId, startedAt, cfg().agentTimeoutSec,
199
+ () => st.lastActivity || '',
200
+ cfg().thinking,
201
+ )
168
202
  } catch (e) {
169
203
  console.error(`[im-bridge] 占位回复失败: ${e.message}`)
170
204
  }
@@ -195,7 +229,7 @@ export function apply(ctx, config) {
195
229
  }
196
230
  }
197
231
 
198
- const ws = new AiBot.WSClient({ botId: cfg().botId, secret: cfg().secret })
232
+ const ws = new AiBot.WSClient({ botId, secret })
199
233
 
200
234
  ws.on('connected', () => console.log('[im-bridge] WebSocket 已连接'))
201
235
  ws.on('authenticated', () => console.log('[im-bridge] 认证成功, 等待消息...'))
@@ -208,7 +242,7 @@ export function apply(ctx, config) {
208
242
  if (!content) return
209
243
  const sender = frame.body?.sender?.userid || frame.body?.from?.userid || frame.body?.userid || 'unknown'
210
244
  if (cfg().allowFrom.length > 0 && !cfg().allowFrom.includes(sender)) {
211
- ws.replyStream(frame, AiBot.generateReqId('stream'), '无权访问本服务', true).catch(() => {})
245
+ ws.replyStream(frame, generateReqId('stream'), cfg().deniedMessage, true).catch(() => {})
212
246
  return
213
247
  }
214
248
  console.log(`[im-bridge] 收到 from=${sender}: ${content.slice(0, 100)}`)
@@ -224,7 +258,7 @@ export function apply(ctx, config) {
224
258
  console.log(`[im-bridge] 用户 ${sender} 进入会话`)
225
259
  ws.replyWelcome(frame, {
226
260
  msgtype: 'text',
227
- text: { content: '👋 云PC 诊断助手已就绪。直接发消息即可让我诊断设备/服务状态。' },
261
+ text: { content: cfg().welcomeMessage },
228
262
  }).catch((e) => console.error(`[im-bridge] 欢迎语失败: ${e.message}`))
229
263
  })
230
264
 
package/src/wecom.js CHANGED
@@ -2,7 +2,31 @@
2
2
  * wecom.js — 企业微信交互封装: 流式动画(v3) + 最终简报 + 发送助手。
3
3
  * 逻辑移植自旧版 im-bridge/bridge.js, 改为纯 ESM 供插件使用。
4
4
  */
5
- import AiBot from '@wecom/aibot-node-sdk'
5
+ import { generateReqId } from '@wecom/aibot-node-sdk'
6
+
7
+ /** 流式思考动画的默认素材(可被 Config.thinking / cordis patch 覆盖) */
8
+ export const DEFAULT_THINKING = {
9
+ phases: [
10
+ { atSec: 0, text: '🤔 正在理解你的需求…' },
11
+ { atSec: 8, text: '📋 正在整理任务清单' },
12
+ { atSec: 25, text: '🔍 正在查找相关资料' },
13
+ { atSec: 55, text: '✍️ 正在处理文档/数据' },
14
+ { atSec: 120, text: '🧠 正在思考最佳方案…' },
15
+ { atSec: 240, text: '⏳ 任务较繁琐,请稍候…' },
16
+ { atSec: 420, text: '☕ 快好了,正在收尾…' },
17
+ ],
18
+ spin: ['🧠', '💭', '✨', '🔎', '⚡'],
19
+ eggs: [
20
+ '📎 顺手把要点整理好了,稍后一起给你',
21
+ '📶 网络有点忙,让它慢慢跑',
22
+ '🎯 结果快出来了,坚持一下',
23
+ '🗂️ 资料较多,正在汇总中',
24
+ '🌙 别盯着了,完成会自动通知你',
25
+ ],
26
+ eggAfterSec: 240,
27
+ intervalMs: 1500,
28
+ activityPrefix: '🛠️ 正在执行 ',
29
+ }
6
30
 
7
31
  /** 毫秒 → 人类可读时长(如 "9 分 47 秒") */
8
32
  export function fmtDuration(ms) {
@@ -37,46 +61,42 @@ export function truncate(text, max) {
37
61
  }
38
62
 
39
63
  /**
40
- * 流式动画 v3(办公助手人设): 阶段化人格台词 + 旋转表情 + 进度条 + 已用时/剩余估算 + 长任务彩蛋。
41
- * 每 1.5s 更新一次同一条流式消息(共用 streamId), 直到 agent 完成。返回停止函数。
42
- * @param {() => string} [activity] - 可选: 返回当前真实活动描述(如 "📋 正在执行 <操作>"), 覆盖阶段台词。
64
+ * 流式动画 v3: 阶段化台词 + 旋转表情 + 进度条 + 已用时/剩余估算 + 长任务彩蛋。
65
+ * 每 intervalMs 更新一次同一条流式消息(共用 streamId), 直到 agent 完成。返回停止函数。
66
+ * @param {() => string} [activity] - 可选: 返回当前真实活动描述, 覆盖阶段台词。
67
+ * @param {typeof DEFAULT_THINKING} [thinking] - 动画素材; 缺省用 {@link DEFAULT_THINKING}。
43
68
  */
44
- export function startThinking(ws, frame, streamId, startedAt, timeoutSec, activity) {
45
- const PHASES = [
46
- [0, '🤔 正在理解你的需求…'],
47
- [8, '📋 正在整理任务清单'],
48
- [25, '🔍 正在查找相关资料'],
49
- [55, '✍️ 正在处理文档/数据'],
50
- [120, '🧠 正在思考最佳方案…'],
51
- [240, '⏳ 任务较繁琐,请稍候…'],
52
- [420, '☕ 快好了,正在收尾…'],
53
- ]
54
- const SPIN = ['🧠', '💭', '✨', '🔎', '⚡']
55
- const EGGS = [
56
- '📎 顺手把要点整理好了,稍后一起给你',
57
- '📶 网络有点忙,让它慢慢跑',
58
- '🎯 结果快出来了,坚持一下',
59
- '🗂️ 资料较多,正在汇总中',
60
- '🌙 别盯着了,完成会自动通知你',
61
- ]
69
+ export function startThinking(ws, frame, streamId, startedAt, timeoutSec, activity, thinking) {
70
+ const t = { ...DEFAULT_THINKING, ...(thinking || {}) }
71
+ const phases = Array.isArray(t.phases) && t.phases.length > 0 ? t.phases : DEFAULT_THINKING.phases
72
+ const spin = Array.isArray(t.spin) && t.spin.length > 0 ? t.spin : DEFAULT_THINKING.spin
73
+ const eggs = Array.isArray(t.eggs) && t.eggs.length > 0 ? t.eggs : DEFAULT_THINKING.eggs
74
+ const eggAfterSec = Number.isFinite(t.eggAfterSec) ? t.eggAfterSec : DEFAULT_THINKING.eggAfterSec
75
+ const intervalMs = Number.isFinite(t.intervalMs) && t.intervalMs > 0 ? t.intervalMs : DEFAULT_THINKING.intervalMs
62
76
  const total = Number.isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec : 600
63
77
  let i = 0
64
78
  const timer = setInterval(() => {
65
79
  const secs = Math.floor((Date.now() - startedAt) / 1000)
66
80
  const live = activity ? activity() : ''
67
- let stage = PHASES[0][1]
68
- if (!live) for (const [t, s] of PHASES) if (secs >= t) stage = s
81
+ let stage = phases[0].text
82
+ if (!live) {
83
+ for (const p of phases) {
84
+ if (secs >= p.atSec) stage = p.text
85
+ }
86
+ }
69
87
  const pct = Math.min(Math.floor((secs / total) * 100), 99)
70
88
  const filled = '█'.repeat(Math.floor(pct / 10))
71
89
  const bar = secs < 3 ? '' : `\n${filled}${'░'.repeat(10 - filled.length)} ${String(pct).padStart(2)}%`
72
90
  const remain = Math.max(total - secs, 0)
73
91
  const remainTxt = secs < 3 ? '' : ` · 预计还剩 ${Math.floor(remain / 60)}分${remain % 60}秒`
74
- const egg = secs >= 240 ? `\n${EGGS[Math.floor(secs / 60) % EGGS.length]}` : ''
75
- const emoji = SPIN[i % SPIN.length]
92
+ const egg = secs >= eggAfterSec && eggs.length > 0
93
+ ? `\n${eggs[Math.floor(secs / 60) % eggs.length]}`
94
+ : ''
95
+ const emoji = spin[i % spin.length]
76
96
  i++
77
97
  const status = live || stage
78
98
  ws.replyStream(frame, streamId, `${emoji} ${status} ⏱ ${secs} 秒${remainTxt}${bar}${egg}`, false).catch(() => {})
79
- }, 1500)
99
+ }, intervalMs)
80
100
  return () => clearInterval(timer)
81
101
  }
82
102
 
@@ -86,6 +106,6 @@ export async function sendFinal(ws, frame, streamId, content) {
86
106
  await ws.replyStream(frame, streamId, content, true)
87
107
  } catch (e) {
88
108
  console.error(`[im-bridge] 原流最终回复失败(${e.message}), 尝试新流...`)
89
- await ws.replyStream(frame, AiBot.generateReqId('stream'), content, true)
109
+ await ws.replyStream(frame, generateReqId('stream'), content, true)
90
110
  }
91
111
  }