@mhfire/dsh-im-bridge 0.1.0 → 0.1.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.md +3 -1
- package/package.json +1 -1
- package/src/index.js +71 -60
- package/src/wecom.js +2 -2
package/README.md
CHANGED
|
@@ -23,11 +23,13 @@ dsh plugin --profile web add <本包路径>
|
|
|
23
23
|
|
|
24
24
|
重启 dsh 进程即可使用。
|
|
25
25
|
|
|
26
|
+
未配置 `botId` / `secret` 时插件仍会加载(不阻塞 `dsh web`),日志会提示跳过企微连线;Settings → 插件配置页仍可填写,保存后**重启**进程才会连接(本版本不做热启连)。
|
|
27
|
+
|
|
26
28
|
## 配置项
|
|
27
29
|
|
|
28
30
|
| 字段 | 说明 |
|
|
29
31
|
|---|---|
|
|
30
|
-
| `botId` / `secret` | 企业微信智能机器人凭证(`role('secret')`,UI
|
|
32
|
+
| `botId` / `secret` | 企业微信智能机器人凭证(`role('secret')`,UI 自动脱敏);缺省时跳过企微侧,不阻塞主进程 |
|
|
31
33
|
| `workspace` | Agent 工作目录(会话 cwd) |
|
|
32
34
|
| `allowFrom` | 允许的发送者 userid 白名单;空 = 允许所有人 |
|
|
33
35
|
| `agentTimeoutSec` | 单任务最长执行时间(秒),动画进度条/剩余估算的基准 |
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -27,10 +27,10 @@ export const name = 'im-bridge'
|
|
|
27
27
|
/** 依赖的核心服务 */
|
|
28
28
|
export const inject = ['agents', 'sessions', 'agentDefaultModel']
|
|
29
29
|
|
|
30
|
-
/** 插件配置(zod): 敏感项由 profile 层 patch
|
|
30
|
+
/** 插件配置(zod): 敏感项由 profile 层 patch 或 Settings 提供;缺省时跳过企微连线,不阻塞主进程 */
|
|
31
31
|
export const Config = z.object({
|
|
32
|
-
botId: z.string().
|
|
33
|
-
secret: z.string().
|
|
32
|
+
botId: z.string().default('').role('secret'),
|
|
33
|
+
secret: z.string().default('').role('secret'),
|
|
34
34
|
/** Agent 的工作目录(会话 cwd) */
|
|
35
35
|
workspace: z.string().default(process.cwd()),
|
|
36
36
|
/** 允许的发送者 userid 白名单; 空 = 允许所有人 */
|
|
@@ -90,75 +90,86 @@ export function apply(ctx, config) {
|
|
|
90
90
|
throw new Error('im-bridge: 需要 agents/sessions/agentDefaultModel 服务')
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
// per-sender 持久会话状态: sender -> { agent, sessionId, queue, lastActivity }
|
|
94
|
-
const senders = new Map()
|
|
95
|
-
const persona = resolvePersona(config)
|
|
96
|
-
|
|
97
93
|
// ── 设置命名空间: 用户可在 GUI 插件配置页 / settings.yaml 覆盖字段(热生效) ──
|
|
98
|
-
|
|
94
|
+
// 规范做法(与 @deepseek-ai/dsh-settings 的 installSettingsSection 一致):
|
|
95
|
+
// 用 ctx.inject(['settings'], cb) 延迟到 settings 服务可用时再注册,
|
|
96
|
+
// 避免 apply 时 settings 尚未挂载导致命名空间缺失(GUI 卡片显示"命名空间不可用")。
|
|
97
|
+
// 缺凭证时仍注册, 便于在 Settings 补齐后再重启启用企微。
|
|
99
98
|
let scope
|
|
100
|
-
|
|
101
|
-
scope = settings.register('im-bridge', Config, { base: { ...config } })
|
|
102
|
-
}
|
|
99
|
+
ctx.inject(['settings'], (sctx) => {
|
|
100
|
+
scope = sctx.settings.register('im-bridge', Config, { base: { ...config } })
|
|
101
|
+
})
|
|
103
102
|
/** 有效配置: 默认值 → cordis patch(base) → 用户 settings.yaml, 热更新 */
|
|
104
103
|
const cfg = () => scope?.value ?? config
|
|
105
104
|
|
|
106
|
-
|
|
107
|
-
async
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
let resolvedId = cfg().agentPreset
|
|
116
|
-
if (presets !== undefined) {
|
|
117
|
-
resolvedId = (await presets.resolve(cfg().agentPreset)).id
|
|
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
|
|
118
114
|
}
|
|
119
|
-
const { agent } = await agents.create({
|
|
120
|
-
sessionId,
|
|
121
|
-
meta: { cwd: cfg().workspace, agentPreset: resolvedId },
|
|
122
|
-
agentOptions: { provider: selection.provider, model: selection.model },
|
|
123
|
-
setup: async (agentCtx) => {
|
|
124
|
-
const selected = { current: selection, assembled: undefined }
|
|
125
|
-
installModelSelection(agentCtx, selected)
|
|
126
|
-
if (presets !== undefined) await presets.mount(agentCtx, resolvedId)
|
|
127
|
-
if (persona !== '') {
|
|
128
|
-
agentCtx.inject(['systemPrompt'], (promptCtx) => {
|
|
129
|
-
promptCtx.systemPrompt.section({
|
|
130
|
-
name: 'deployment:persona', // 同名 scoped section 覆盖部署 persona(仅本 agent)
|
|
131
|
-
order: 0,
|
|
132
|
-
text: persona,
|
|
133
|
-
})
|
|
134
|
-
})
|
|
135
|
-
}
|
|
136
|
-
},
|
|
137
|
-
})
|
|
138
|
-
st = { agent, sessionId, queue: Promise.resolve(), lastActivity: '' }
|
|
139
|
-
senders.set(sender, st)
|
|
140
|
-
console.log(`[im-bridge] 为 ${sender} 创建会话 ${sessionId}`)
|
|
141
|
-
return st
|
|
142
|
-
}
|
|
143
115
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
149
156
|
}
|
|
150
|
-
})
|
|
151
157
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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')
|
|
156
167
|
|
|
157
168
|
/** 处理一条消息: 动画 → followup → 汇总 → 简报 */
|
|
158
169
|
async function handle(frame, sender, content) {
|
|
159
170
|
const st = await ensureAgent(sender)
|
|
160
171
|
const startedAt = Date.now()
|
|
161
|
-
const streamId =
|
|
172
|
+
const streamId = generateReqId('stream')
|
|
162
173
|
let stopThinking = null
|
|
163
174
|
try {
|
|
164
175
|
await ws.replyStream(frame, streamId, cfg().startHint, false)
|
|
@@ -193,7 +204,7 @@ export function apply(ctx, config) {
|
|
|
193
204
|
}
|
|
194
205
|
}
|
|
195
206
|
|
|
196
|
-
const ws = new AiBot.WSClient({ botId
|
|
207
|
+
const ws = new AiBot.WSClient({ botId, secret })
|
|
197
208
|
|
|
198
209
|
ws.on('connected', () => console.log('[im-bridge] WebSocket 已连接'))
|
|
199
210
|
ws.on('authenticated', () => console.log('[im-bridge] 认证成功, 等待消息...'))
|
|
@@ -206,7 +217,7 @@ export function apply(ctx, config) {
|
|
|
206
217
|
if (!content) return
|
|
207
218
|
const sender = frame.body?.sender?.userid || frame.body?.from?.userid || frame.body?.userid || 'unknown'
|
|
208
219
|
if (cfg().allowFrom.length > 0 && !cfg().allowFrom.includes(sender)) {
|
|
209
|
-
ws.replyStream(frame,
|
|
220
|
+
ws.replyStream(frame, generateReqId('stream'), '无权访问本服务', true).catch(() => {})
|
|
210
221
|
return
|
|
211
222
|
}
|
|
212
223
|
console.log(`[im-bridge] 收到 from=${sender}: ${content.slice(0, 100)}`)
|
package/src/wecom.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* wecom.js — 企业微信交互封装: 流式动画(v3) + 最终简报 + 发送助手。
|
|
3
3
|
* 逻辑移植自旧版 im-bridge/bridge.js, 改为纯 ESM 供插件使用。
|
|
4
4
|
*/
|
|
5
|
-
import
|
|
5
|
+
import { generateReqId } from '@wecom/aibot-node-sdk'
|
|
6
6
|
|
|
7
7
|
/** 毫秒 → 人类可读时长(如 "9 分 47 秒") */
|
|
8
8
|
export function fmtDuration(ms) {
|
|
@@ -86,6 +86,6 @@ export async function sendFinal(ws, frame, streamId, content) {
|
|
|
86
86
|
await ws.replyStream(frame, streamId, content, true)
|
|
87
87
|
} catch (e) {
|
|
88
88
|
console.error(`[im-bridge] 原流最终回复失败(${e.message}), 尝试新流...`)
|
|
89
|
-
await ws.replyStream(frame,
|
|
89
|
+
await ws.replyStream(frame, generateReqId('stream'), content, true)
|
|
90
90
|
}
|
|
91
91
|
}
|