@mhfire/dsh-im-bridge 0.1.2 → 0.1.7

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/src/index.js CHANGED
@@ -1,247 +1,401 @@
1
- /**
2
- * dsh-im-bridge — 企业微信智能机器人 ⇄ DSH Agent 桥接插件。
3
- *
4
- * 按 DeepSeek Harness 插件标准实现:
5
- * - name / Config(zod) / apply(ctx, config) 标准形态, 通过 cordis.patch.yml 挂载;
6
- * - 挂进 web profile: 消息在【进程内】通过 agents.create() 创建 Agent,
7
- * per-sender 持久会话(同一企业微信用户复用同一会话, 有上下文记忆);
8
- * - 会话与 GUI 同进程注册 → 在 Web GUI 中实时可见(顺带根治 repair 污染活日志问题);
9
- * - 流式事件经 replyStream 推送动画/简报; 生命周期由 dsh 统一管理。
10
- *
11
- * 启动优化: @wecom/aibot-node-sdk(约 114ms 导入 + 建连初始化)延迟到 loader settle 之后
12
- * 动态加载, 避免拖慢主进程启动关键路径。
13
- *
14
- * 旧版 bridge.js(外部 spawn `dsh --profile headless`)保留在 im-bridge/ 根目录作回退。
15
- */
16
- import { randomUUID } from 'node:crypto'
17
- import { readFileSync } from 'node:fs'
18
- import z from '@deepseek-ai/schemastery'
19
- import { createUserMessage } from '@deepseek-ai/dsh-llm'
20
- import { SessionId } from '@deepseek-ai/dsh-session'
21
- import { installModelSelection } from '@deepseek-ai/dsh-agent'
22
- import { startThinking, sendFinal, truncate, footerOf, fmtDuration } from './wecom.js'
23
-
24
- /** 稳定插件名 */
25
- export const name = 'im-bridge'
26
-
27
- /** 依赖的核心服务 */
28
- export const inject = ['agents', 'sessions', 'agentDefaultModel']
29
-
30
- /** 插件配置(zod): 敏感项由 profile 层 patch 或 Settings 提供;缺省时跳过企微连线,不阻塞主进程 */
31
- export const Config = z.object({
32
- botId: z.string().default('').role('secret'),
33
- secret: z.string().default('').role('secret'),
34
- /** Agent 的工作目录(会话 cwd) */
35
- workspace: z.string().default(process.cwd()),
36
- /** 允许的发送者 userid 白名单; 空 = 允许所有人 */
37
- allowFrom: z.array(String).default([]),
38
- /** 占位提示语 */
39
- startHint: z.string().default('🧠 正在思考...'),
40
- /** 动画进度条/剩余估算的超时基准 */
41
- agentTimeoutSec: z.number().default(600),
42
- /** Agent 加入的 preset(web profile 下默认 standard) */
43
- agentPreset: z.string().default('standard'),
44
- /** 覆盖默认 persona 的文本(可选; 为空用 preset 自带) */
45
- persona: z.string().default(''),
46
- /** 从文件读取 persona(可选; 优先于 persona 字段, 便于维护长文本) */
47
- personaFile: z.string().default(''),
48
- /** 回复上限(字节) */
49
- maxReplyBytes: z.number().default(20000),
50
- })
51
-
52
- /** 收集一次 turn 内最后一条 assistant 文本与结束原因 */
53
- function summarize(events, firstSeq) {
54
- let started = false
55
- let text = ''
56
- let reason
57
- for (const event of events) {
58
- if (event.seq < firstSeq) continue
59
- if (event.type === 'turn/start') { started = true; continue }
60
- if (!started) continue
61
- if (event.type === 'assistant/message') {
62
- const joined = event.data.message.content
63
- .filter((block) => block.type === 'text')
64
- .map((block) => block.text)
65
- .join('')
66
- if (joined !== '') text = joined
67
- }
68
- if (event.type === 'turn/end') reason = event.data.reason
69
- }
70
- return { text, reason }
71
- }
72
-
73
- /** 读取 persona: personaFile 优先, 其次 persona 字段 */
74
- function resolvePersona(config) {
75
- if (config.personaFile) {
76
- try {
77
- return readFileSync(config.personaFile, 'utf8')
78
- } catch (e) {
79
- console.error(`[im-bridge] 读取 personaFile 失败: ${e.message}`)
80
- }
81
- }
82
- return config.persona
83
- }
84
-
85
- export function apply(ctx, config) {
86
- const agents = ctx.get('agents')
87
- const sessions = ctx.get('sessions')
88
- const defaultModel = ctx.get('agentDefaultModel')
89
- if (agents === undefined || sessions === undefined || defaultModel === undefined) {
90
- throw new Error('im-bridge: 需要 agents/sessions/agentDefaultModel 服务')
91
- }
92
-
93
- // ── 设置命名空间: 用户可在 GUI 插件配置页 / settings.yaml 覆盖字段(热生效) ──
94
- // 规范做法(与 @deepseek-ai/dsh-settings 的 installSettingsSection 一致):
95
- // 用 ctx.inject(['settings'], cb) 延迟到 settings 服务可用时再注册,
96
- // 避免 apply settings 尚未挂载导致命名空间缺失(GUI 卡片显示"命名空间不可用")。
97
- // 缺凭证时仍注册, 便于在 Settings 补齐后再重启启用企微。
98
- let scope
99
- ctx.inject(['settings'], (sctx) => {
100
- scope = sctx.settings.register('im-bridge', Config, { base: { ...config } })
101
- })
102
- /** 有效配置: 默认值 → cordis patch(base) 用户 settings.yaml, 热更新 */
103
- const cfg = () => scope?.value ?? config
104
-
105
- // ── 企微 WebSocket: 延迟到 loader settle 后启动, 不占启动关键路径 ──
106
- void (async () => {
107
- await ctx.get('loader')?.await()
108
- const { botId, secret } = cfg()
109
- if (!botId || !secret) {
110
- console.warn(
111
- '[im-bridge] 跳过启动: 缺少 botId/secret。请在 profile cordis.patch.yml 或 Settings → 插件配置中填写后重启。',
112
- )
113
- return
114
- }
115
-
116
- // per-sender 持久会话状态: sender -> { agent, sessionId, queue, lastActivity }
117
- const senders = new Map()
118
- const persona = resolvePersona(cfg())
119
-
120
- /** 创建(或复用)某发送者的 Agent(会话/上下文持久于进程内) */
121
- async function ensureAgent(sender) {
122
- let st = senders.get(sender)
123
- if (st !== undefined && st.agent !== undefined) return st
124
- const sessionId = SessionId(`session-${randomUUID()}`)
125
- const selection = defaultModel.currentSelection()
126
- // preset roster 的部署(如 web profile)必须在 setup 里 mount,
127
- // 否则 agent 看不到任何工具(模型只能编造工具调用)。
128
- const presets = ctx.get('agentPresets')
129
- let resolvedId = cfg().agentPreset
130
- if (presets !== undefined) {
131
- resolvedId = (await presets.resolve(cfg().agentPreset)).id
132
- }
133
- const { agent } = await agents.create({
134
- sessionId,
135
- meta: { cwd: cfg().workspace, agentPreset: resolvedId },
136
- agentOptions: { provider: selection.provider, model: selection.model },
137
- setup: async (agentCtx) => {
138
- const selected = { current: selection, assembled: undefined }
139
- installModelSelection(agentCtx, selected)
140
- if (presets !== undefined) await presets.mount(agentCtx, resolvedId)
141
- if (persona !== '') {
142
- agentCtx.inject(['systemPrompt'], (promptCtx) => {
143
- promptCtx.systemPrompt.section({
144
- name: 'deployment:persona', // 同名 scoped section 覆盖部署 persona(仅本 agent)
145
- order: 0,
146
- text: persona,
147
- })
148
- })
149
- }
150
- },
151
- })
152
- st = { agent, sessionId, queue: Promise.resolve(), lastActivity: '' }
153
- senders.set(sender, st)
154
- console.log(`[im-bridge] ${sender} 创建会话 ${sessionId}`)
155
- return st
156
- }
157
-
158
- // 会话事件 真实活动状态(工具调用名), 让动画显示"正在做什么"
159
- ctx.on('session/event', (session, event) => {
160
- if (event.type !== 'tool/call') return
161
- for (const st of senders.values()) {
162
- if (st.sessionId === session.id) st.lastActivity = `🛠️ 正在执行 ${event.data.name}`
163
- }
164
- })
165
-
166
- const { default: AiBot, generateReqId } = await import('@wecom/aibot-node-sdk')
167
-
168
- /** 处理一条消息: 动画 followup → 汇总 → 简报 */
169
- async function handle(frame, sender, content) {
170
- const st = await ensureAgent(sender)
171
- const startedAt = Date.now()
172
- const streamId = generateReqId('stream')
173
- let stopThinking = null
174
- try {
175
- await ws.replyStream(frame, streamId, cfg().startHint, false)
176
- stopThinking = startThinking(ws, frame, streamId, startedAt, cfg().agentTimeoutSec, () => st.lastActivity || '')
177
- } catch (e) {
178
- console.error(`[im-bridge] 占位回复失败: ${e.message}`)
179
- }
180
- try {
181
- await st.agent.whenIdle()
182
- const firstSeq = st.agent.session.seq
183
- st.agent.followup(createUserMessage({
184
- content: [{ type: 'text', text: content }],
185
- source: { kind: 'user' },
186
- }))
187
- await st.agent.whenIdle()
188
- await sessions.flush(st.agent.session)
189
- const outcome = summarize(st.agent.session.events, firstSeq)
190
- if (stopThinking) stopThinking()
191
- const ms = Date.now() - startedAt
192
- const reply = truncate(outcome.text || '(agent 无输出)', (cfg().maxReplyBytes || 20000) - 200) + footerOf(ms)
193
- console.log(`[im-bridge] ${sender} 完成 (${Buffer.byteLength(reply, 'utf8')}B, ${fmtDuration(ms)})`)
194
- await sendFinal(ws, frame, streamId, reply)
195
- } catch (e) {
196
- if (stopThinking) stopThinking()
197
- const ms = Date.now() - startedAt
198
- console.error(`[im-bridge] agent 失败: ${e.message}`)
199
- try {
200
- await sendFinal(ws, frame, streamId, `处理失败: ${truncate(e.message, 400)}\n\n---\n❌ 耗时 ${fmtDuration(ms)}`)
201
- } catch (e2) {
202
- console.error(`[im-bridge] 错误回复也失败: ${e2.message}`)
203
- }
204
- }
205
- }
206
-
207
- const ws = new AiBot.WSClient({ botId, secret })
208
-
209
- ws.on('connected', () => console.log('[im-bridge] WebSocket 已连接'))
210
- ws.on('authenticated', () => console.log('[im-bridge] 认证成功, 等待消息...'))
211
- ws.on('disconnected', (r) => console.log(`[im-bridge] 断开: ${r}`))
212
- ws.on('reconnecting', (n) => console.log(`[im-bridge] 第 ${n} 次重连...`))
213
- ws.on('error', (e) => console.error(`[im-bridge] 错误: ${e.message}`))
214
-
215
- ws.on('message.text', (frame) => {
216
- const content = (frame.body?.text?.content || '').trim()
217
- if (!content) return
218
- const sender = frame.body?.sender?.userid || frame.body?.from?.userid || frame.body?.userid || 'unknown'
219
- if (cfg().allowFrom.length > 0 && !cfg().allowFrom.includes(sender)) {
220
- ws.replyStream(frame, generateReqId('stream'), '无权访问本服务', true).catch(() => {})
221
- return
222
- }
223
- console.log(`[im-bridge] 收到 from=${sender}: ${content.slice(0, 100)}`)
224
- const st = senders.get(sender) ?? { queue: Promise.resolve() }
225
- senders.set(sender, st)
226
- st.queue = st.queue
227
- .then(() => handle(frame, sender, content))
228
- .catch((e) => console.error(`[im-bridge] 任务异常: ${e.message}`))
229
- })
230
-
231
- ws.on('event.enter_chat', (frame) => {
232
- const sender = frame.body?.from?.userid || 'unknown'
233
- console.log(`[im-bridge] 用户 ${sender} 进入会话`)
234
- ws.replyWelcome(frame, {
235
- msgtype: 'text',
236
- text: { content: '👋 云PC 诊断助手已就绪。直接发消息即可让我诊断设备/服务状态。' },
237
- }).catch((e) => console.error(`[im-bridge] 欢迎语失败: ${e.message}`))
238
- })
239
-
240
- ws.connect()
241
-
242
- // 生命周期: dsh 关闭时断开企微连接
243
- ctx.on('dispose', () => {
244
- try { ws.close?.() } catch { /* 已关闭 */ }
245
- })
246
- })()
247
- }
1
+ /**
2
+ * dsh-im-bridge — 企业微信智能机器人 ⇄ DSH Agent 桥接插件。
3
+ *
4
+ * 按 DeepSeek Harness 插件标准实现:
5
+ * - name / Config(zod) / apply(ctx, config) 标准形态, 通过 cordis.patch.yml 挂载;
6
+ * - 挂进 web profile: 消息在【进程内】通过 agents.create() 创建 Agent,
7
+ * per-sender 持久会话(同一企业微信用户复用同一会话, 有上下文记忆);
8
+ * - 会话与 GUI 同进程注册 → 在 Web GUI 中实时可见(顺带根治 repair 污染活日志问题);
9
+ * - 流式事件经 replyStream 推送动画/简报; 生命周期由 dsh 统一管理。
10
+ *
11
+ * 启动优化: @wecom/aibot-node-sdk(约 114ms 导入 + 建连初始化)延迟到 loader settle 之后
12
+ * 动态加载, 避免拖慢主进程启动关键路径。
13
+ *
14
+ * 旧版 bridge.js(外部 spawn `dsh --profile headless`)保留在 im-bridge/ 根目录作回退。
15
+ */
16
+ import { randomUUID } from 'node:crypto'
17
+ import { readFileSync } from 'node:fs'
18
+ import { dirname, join } from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+ import z from '@deepseek-ai/schemastery'
21
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
22
+ import { SessionId } from '@deepseek-ai/dsh-session'
23
+ import { installModelSelection } from '@deepseek-ai/dsh-agent'
24
+ import { startThinking, sendFinal, truncate, footerOf, fmtDuration, DEFAULT_THINKING, labelTool, pickStatusLine, streamPhaseFromChunk } from './wecom.js'
25
+
26
+ /** 包根目录(与 persona.default*.md 同级) */
27
+ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
28
+ /** 包内中文默认人设 */
29
+ const DEFAULT_PERSONA_ZH = join(PACKAGE_ROOT, 'persona.default.md')
30
+ /** 包内英文默认人设 */
31
+ const DEFAULT_PERSONA_EN = join(PACKAGE_ROOT, 'persona.default.en.md')
32
+ /** Host settings 中与 dsh-client-locale 一致的语言命名空间 */
33
+ const LOCALE_SETTINGS_NS = 'locale'
34
+
35
+ /** 稳定插件名 */
36
+ export const name = 'im-bridge'
37
+
38
+ /** 依赖的核心服务 */
39
+ export const inject = ['agents', 'sessions', 'agentDefaultModel']
40
+
41
+ const ThinkingPhase = z.object({
42
+ atSec: z.number(),
43
+ text: z.string(),
44
+ })
45
+
46
+ const ThinkingConfig = z.object({
47
+ phases: z.array(ThinkingPhase).default(DEFAULT_THINKING.phases),
48
+ spin: z.array(String).default(DEFAULT_THINKING.spin),
49
+ eggs: z.array(String).default(DEFAULT_THINKING.eggs),
50
+ eggAfterSec: z.number().default(DEFAULT_THINKING.eggAfterSec),
51
+ intervalMs: z.number().default(DEFAULT_THINKING.intervalMs),
52
+ activityPrefix: z.string().default(DEFAULT_THINKING.activityPrefix),
53
+ /** 工具名 友好显示名; 与内置表合并, 同名覆盖 */
54
+ toolLabels: z.dict(String).default(DEFAULT_THINKING.toolLabels),
55
+ /** 模型推理阶段状态行(轮换) */
56
+ reasoningStatus: z.array(String).default(DEFAULT_THINKING.reasoningStatus),
57
+ /** 模型正文输出阶段状态行(轮换) */
58
+ outputStatus: z.array(String).default(DEFAULT_THINKING.outputStatus),
59
+ reasoningSpin: z.array(String).default(DEFAULT_THINKING.reasoningSpin),
60
+ outputSpin: z.array(String).default(DEFAULT_THINKING.outputSpin),
61
+ })
62
+
63
+ /** 插件配置(zod): 敏感项由 profile patch 或 Settings 提供;缺省时跳过企微连线,不阻塞主进程 */
64
+ export const Config = z.object({
65
+ botId: z.string().default('').role('secret'),
66
+ secret: z.string().default('').role('secret'),
67
+ /** Agent 的工作目录(会话 cwd) */
68
+ workspace: z.string().default(process.cwd()),
69
+ /** 允许的发送者 userid 白名单; 空 = 允许所有人 */
70
+ allowFrom: z.array(String).default([]),
71
+ /** 占位提示语 */
72
+ startHint: z.string().default('🧠 正在思考...'),
73
+ /** 动画进度条/剩余估算的超时基准 */
74
+ agentTimeoutSec: z.number().default(600),
75
+ /** Agent 加入的 preset(web profile 下默认 standard) */
76
+ agentPreset: z.string().default('standard'),
77
+ /** 覆盖包内默认人设的文本(可选; 为空且无 personaFile 时按 locale 选默认文件) */
78
+ persona: z.string().default(''),
79
+ /** 从文件读取人设(可选; 优先于 persona; 不跟语言切换; 推荐与 profile 的 cordis.patch.yml 同目录) */
80
+ personaFile: z.string().default(''),
81
+ /** 回复上限(字节) */
82
+ maxReplyBytes: z.number().default(20000),
83
+ /** 流式思考动画素材(阶段/表情/彩蛋等); 可在 profile patch 覆盖 */
84
+ thinking: ThinkingConfig.default(DEFAULT_THINKING),
85
+ /** 非白名单用户的拒绝文案 */
86
+ deniedMessage: z.string().default('无权访问本服务'),
87
+ /** 用户进入会话时的欢迎语 */
88
+ welcomeMessage: z.string().default('👋 办公助手已就绪。直接发消息即可,例如查文件、整理文档、查资料或处理日常事务。'),
89
+ })
90
+
91
+ /** 收集一次 turn 内最后一条 assistant 文本与结束原因 */
92
+ function summarize(events, firstSeq) {
93
+ let started = false
94
+ let text = ''
95
+ let reason
96
+ for (const event of events) {
97
+ if (event.seq < firstSeq) continue
98
+ if (event.type === 'turn/start') { started = true; continue }
99
+ if (!started) continue
100
+ if (event.type === 'assistant/message') {
101
+ const joined = event.data.message.content
102
+ .filter((block) => block.type === 'text')
103
+ .map((block) => block.text)
104
+ .join('')
105
+ if (joined !== '') text = joined
106
+ }
107
+ if (event.type === 'turn/end') reason = event.data.reason
108
+ }
109
+ return { text, reason }
110
+ }
111
+
112
+ /**
113
+ * 读 Host `locale.preference`(`zh`|`en`); 缺失或未知时回退 `zh`
114
+ * (与客户端 FALLBACK_LOCALE 一致; 未显式选择时 Host 不知浏览器实际语言)。
115
+ * @param {{ get?: (ns: string) => unknown } | undefined} settings
116
+ * @returns {'zh' | 'en'}
117
+ */
118
+ function readLocalePreference(settings) {
119
+ if (settings?.get === undefined) return 'zh'
120
+ try {
121
+ const section = settings.get(LOCALE_SETTINGS_NS)
122
+ const pref = section && typeof section === 'object' && 'preference' in section
123
+ ? section.preference
124
+ : undefined
125
+ return pref === 'en' ? 'en' : 'zh'
126
+ } catch {
127
+ return 'zh'
128
+ }
129
+ }
130
+
131
+ /**
132
+ * 去掉文件开头连续的 `# …` 行与紧随空行(仅用于包内默认人设维护说明)。
133
+ * 不对用户 personaFile 调用: 用户可能故意用 `#` 作标题。
134
+ * @param {string} text
135
+ * @returns {string}
136
+ */
137
+ function stripLeadingHashComments(text) {
138
+ const lines = text.split(/\r?\n/)
139
+ let i = 0
140
+ while (i < lines.length && /^\s*#/.test(lines[i])) i++
141
+ while (i < lines.length && lines[i].trim() === '') i++
142
+ return lines.slice(i).join('\n')
143
+ }
144
+
145
+ /**
146
+ * 读取人设: personaFile → persona 字符串 → 包内默认(按 locale.preference 选 zh/en)
147
+ * @param {z.infer<typeof Config>} config
148
+ * @param {{ get?: (ns: string) => unknown } | undefined} settings
149
+ */
150
+ function resolvePersona(config, settings) {
151
+ if (config.personaFile) {
152
+ try {
153
+ return readFileSync(config.personaFile, 'utf8')
154
+ } catch (e) {
155
+ console.error(`[im-bridge] 读取 personaFile 失败: ${e.message}`)
156
+ }
157
+ }
158
+ if (config.persona !== '') return config.persona
159
+ const file = readLocalePreference(settings) === 'en' ? DEFAULT_PERSONA_EN : DEFAULT_PERSONA_ZH
160
+ try {
161
+ return stripLeadingHashComments(readFileSync(file, 'utf8'))
162
+ } catch (e) {
163
+ console.error(`[im-bridge] 读取默认人设失败: ${e.message}`)
164
+ return ''
165
+ }
166
+ }
167
+
168
+ export function apply(ctx, config) {
169
+ const agents = ctx.get('agents')
170
+ const sessions = ctx.get('sessions')
171
+ const defaultModel = ctx.get('agentDefaultModel')
172
+ if (agents === undefined || sessions === undefined || defaultModel === undefined) {
173
+ throw new Error('im-bridge: 需要 agents/sessions/agentDefaultModel 服务')
174
+ }
175
+
176
+ // ── 设置命名空间: 用户可在 GUI 插件配置页 / settings.yaml 覆盖字段(热生效) ──
177
+ // 规范做法(与 @deepseek-ai/dsh-settings 的 installSettingsSection 一致):
178
+ // 用 ctx.inject(['settings'], cb) 延迟到 settings 服务可用时再注册,
179
+ // 避免 apply 时 settings 尚未挂载导致命名空间缺失(GUI 卡片显示"命名空间不可用")。
180
+ // 缺凭证时仍注册, 便于在 Settings 补齐后再重启启用企微。
181
+ let scope
182
+ /** @type {{ get: (ns: string) => unknown } | undefined} */
183
+ let settings
184
+ ctx.inject(['settings'], (sctx) => {
185
+ settings = sctx.settings
186
+ scope = sctx.settings.register('im-bridge', Config, { base: { ...config } })
187
+ })
188
+ /** 有效配置: 默认值 → cordis patch(base) → 用户 settings.yaml, 热更新 */
189
+ const cfg = () => scope?.value ?? config
190
+
191
+ // ── 企微 WebSocket: 延迟到 loader settle 后启动, 不占启动关键路径 ──
192
+ void (async () => {
193
+ await ctx.get('loader')?.await()
194
+ const { botId, secret } = cfg()
195
+ if (!botId || !secret) {
196
+ console.warn(
197
+ '[im-bridge] 跳过启动: 缺少 botId/secret。请在 profile cordis.patch.yml Settings → 插件配置中填写后重启。',
198
+ )
199
+ return
200
+ }
201
+
202
+ // per-sender 持久会话状态: sender -> { agent, sessionId, queue, lastActivity }
203
+ const senders = new Map()
204
+
205
+ /** 创建(或复用)某发送者的 Agent(会话/上下文持久于进程内) */
206
+ async function ensureAgent(sender) {
207
+ let st = senders.get(sender)
208
+ if (st !== undefined && st.agent !== undefined) return st
209
+ const sessionId = SessionId(`session-${randomUUID()}`)
210
+ const selection = defaultModel.currentSelection()
211
+ // preset roster 的部署( web profile)必须在 setup 里 mount,
212
+ // 否则 agent 看不到任何工具(模型只能编造工具调用)
213
+ const presets = ctx.get('agentPresets')
214
+ let resolvedId = cfg().agentPreset
215
+ if (presets !== undefined) {
216
+ resolvedId = (await presets.resolve(cfg().agentPreset)).id
217
+ }
218
+ const { agent } = await agents.create({
219
+ sessionId,
220
+ meta: { cwd: cfg().workspace, agentPreset: resolvedId },
221
+ agentOptions: { provider: selection.provider, model: selection.model },
222
+ setup: async (agentCtx) => {
223
+ const selected = { current: selection, assembled: undefined }
224
+ installModelSelection(agentCtx, selected)
225
+ if (presets !== undefined) await presets.mount(agentCtx, resolvedId)
226
+ // 每次 assemble 再解析: 默认人设跟 Host locale.preference; 覆盖文件不跟切
227
+ agentCtx.inject(['systemPrompt'], (promptCtx) => {
228
+ promptCtx.systemPrompt.section({
229
+ name: 'deployment:persona', // 同名 scoped section 覆盖部署 persona(仅本 agent)
230
+ order: 0,
231
+ text: () => resolvePersona(cfg(), settings),
232
+ })
233
+ })
234
+ },
235
+ })
236
+ st = {
237
+ agent,
238
+ sessionId,
239
+ queue: Promise.resolve(),
240
+ lastActivity: '',
241
+ activityClearAt: 0,
242
+ lastToolByCallId: new Map(),
243
+ modelStreamPhase: 'idle',
244
+ streamStatusTick: 0,
245
+ }
246
+ senders.set(sender, st)
247
+ console.log(`[im-bridge] 为 ${sender} 创建会话 ${sessionId}`)
248
+ return st
249
+ }
250
+
251
+ // 优先级: 工具活动 > 模型流式阶段(reasoning/output) > 时间轴 phases
252
+ ctx.on('session/event', (session, event) => {
253
+ const thinking = cfg().thinking
254
+ const prefix = thinking?.activityPrefix ?? DEFAULT_THINKING.activityPrefix
255
+ const flashMs = Number.isFinite(thinking?.intervalMs) && thinking.intervalMs > 0
256
+ ? thinking.intervalMs
257
+ : DEFAULT_THINKING.intervalMs
258
+ for (const st of senders.values()) {
259
+ if (st.sessionId !== session.id) continue
260
+ if (event.type === 'assistant/chunk') {
261
+ const next = streamPhaseFromChunk(event.data.chunk)
262
+ if (next !== null) st.modelStreamPhase = next
263
+ continue
264
+ }
265
+ if (event.type === 'tool/call') {
266
+ const name = event.data.name
267
+ if (event.data.callId !== undefined) st.lastToolByCallId.set(event.data.callId, name)
268
+ st.activityClearAt = 0
269
+ st.lastActivity = `${prefix}${labelTool(name, thinking)}`
270
+ return
271
+ }
272
+ if (event.type === 'tool/result') {
273
+ const callId = event.data.message?.source?.callId
274
+ const rawName = (callId !== undefined && st.lastToolByCallId.get(callId))
275
+ || [...st.lastToolByCallId.values()].at(-1)
276
+ || ''
277
+ if (callId !== undefined) st.lastToolByCallId.delete(callId)
278
+ const label = labelTool(rawName || '工具', thinking)
279
+ const failed = event.data.error !== undefined
280
+ st.lastActivity = failed ? `❌ ${label} 失败` : `✅ ${label} 完成`
281
+ st.activityClearAt = Date.now() + flashMs
282
+ st.modelStreamPhase = 'idle'
283
+ }
284
+ }
285
+ })
286
+
287
+ const { default: AiBot, generateReqId } = await import('@wecom/aibot-node-sdk')
288
+
289
+ /** 处理一条消息: 动画 → followup → 汇总 → 简报 */
290
+ async function handle(frame, sender, content) {
291
+ const st = await ensureAgent(sender)
292
+ const startedAt = Date.now()
293
+ const streamId = generateReqId('stream')
294
+ let stopThinking = null
295
+ st.lastActivity = ''
296
+ st.activityClearAt = 0
297
+ st.lastToolByCallId.clear()
298
+ st.modelStreamPhase = 'idle'
299
+ st.streamStatusTick = 0
300
+ try {
301
+ await ws.replyStream(frame, streamId, cfg().startHint, false)
302
+ stopThinking = startThinking(
303
+ ws, frame, streamId, startedAt, cfg().agentTimeoutSec,
304
+ () => {
305
+ if (st.activityClearAt > 0 && Date.now() >= st.activityClearAt) {
306
+ st.lastActivity = ''
307
+ st.activityClearAt = 0
308
+ }
309
+ if (st.lastActivity) return st.lastActivity
310
+ const thinking = cfg().thinking
311
+ const tick = st.streamStatusTick++
312
+ if (st.modelStreamPhase === 'reasoning') {
313
+ return pickStatusLine(
314
+ thinking?.reasoningStatus,
315
+ DEFAULT_THINKING.reasoningStatus,
316
+ tick,
317
+ )
318
+ }
319
+ if (st.modelStreamPhase === 'outputting') {
320
+ return pickStatusLine(
321
+ thinking?.outputStatus,
322
+ DEFAULT_THINKING.outputStatus,
323
+ tick,
324
+ )
325
+ }
326
+ return ''
327
+ },
328
+ cfg().thinking,
329
+ () => (st.lastActivity ? 'idle' : st.modelStreamPhase),
330
+ )
331
+ } catch (e) {
332
+ console.error(`[im-bridge] 占位回复失败: ${e.message}`)
333
+ }
334
+ try {
335
+ await st.agent.whenIdle()
336
+ const firstSeq = st.agent.session.seq
337
+ st.agent.followup(createUserMessage({
338
+ content: [{ type: 'text', text: content }],
339
+ source: { kind: 'user' },
340
+ }))
341
+ await st.agent.whenIdle()
342
+ await sessions.flush(st.agent.session)
343
+ const outcome = summarize(st.agent.session.events, firstSeq)
344
+ if (stopThinking) stopThinking()
345
+ const ms = Date.now() - startedAt
346
+ const reply = truncate(outcome.text || '(agent 无输出)', (cfg().maxReplyBytes || 20000) - 200) + footerOf(ms)
347
+ console.log(`[im-bridge] ${sender} 完成 (${Buffer.byteLength(reply, 'utf8')}B, ${fmtDuration(ms)})`)
348
+ await sendFinal(ws, frame, streamId, reply)
349
+ } catch (e) {
350
+ if (stopThinking) stopThinking()
351
+ const ms = Date.now() - startedAt
352
+ console.error(`[im-bridge] agent 失败: ${e.message}`)
353
+ try {
354
+ await sendFinal(ws, frame, streamId, `处理失败: ${truncate(e.message, 400)}\n\n---\n❌ 耗时 ${fmtDuration(ms)}`)
355
+ } catch (e2) {
356
+ console.error(`[im-bridge] 错误回复也失败: ${e2.message}`)
357
+ }
358
+ }
359
+ }
360
+
361
+ const ws = new AiBot.WSClient({ botId, secret })
362
+
363
+ ws.on('connected', () => console.log('[im-bridge] WebSocket 已连接'))
364
+ ws.on('authenticated', () => console.log('[im-bridge] 认证成功, 等待消息...'))
365
+ ws.on('disconnected', (r) => console.log(`[im-bridge] 断开: ${r}`))
366
+ ws.on('reconnecting', (n) => console.log(`[im-bridge] 第 ${n} 次重连...`))
367
+ ws.on('error', (e) => console.error(`[im-bridge] 错误: ${e.message}`))
368
+
369
+ ws.on('message.text', (frame) => {
370
+ const content = (frame.body?.text?.content || '').trim()
371
+ if (!content) return
372
+ const sender = frame.body?.sender?.userid || frame.body?.from?.userid || frame.body?.userid || 'unknown'
373
+ if (cfg().allowFrom.length > 0 && !cfg().allowFrom.includes(sender)) {
374
+ ws.replyStream(frame, generateReqId('stream'), cfg().deniedMessage, true).catch(() => {})
375
+ return
376
+ }
377
+ console.log(`[im-bridge] 收到 from=${sender}: ${content.slice(0, 100)}`)
378
+ const st = senders.get(sender) ?? { queue: Promise.resolve() }
379
+ senders.set(sender, st)
380
+ st.queue = st.queue
381
+ .then(() => handle(frame, sender, content))
382
+ .catch((e) => console.error(`[im-bridge] 任务异常: ${e.message}`))
383
+ })
384
+
385
+ ws.on('event.enter_chat', (frame) => {
386
+ const sender = frame.body?.from?.userid || 'unknown'
387
+ console.log(`[im-bridge] 用户 ${sender} 进入会话`)
388
+ ws.replyWelcome(frame, {
389
+ msgtype: 'text',
390
+ text: { content: cfg().welcomeMessage },
391
+ }).catch((e) => console.error(`[im-bridge] 欢迎语失败: ${e.message}`))
392
+ })
393
+
394
+ ws.connect()
395
+
396
+ // 生命周期: dsh 关闭时断开企微连接
397
+ ctx.on('dispose', () => {
398
+ try { ws.close?.() } catch { /* 已关闭 */ }
399
+ })
400
+ })()
401
+ }