@mhfire/dsh-im-bridge 0.1.3 → 0.2.0

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/wecom.ts ADDED
@@ -0,0 +1,234 @@
1
+ /**
2
+ * WeCom stream helpers: thinking animation, final brief, and send retries.
3
+ * The WeCom SDK is imported only on the final-send retry path so plugin load
4
+ * does not pull it in before Loader settle.
5
+ */
6
+
7
+ /** One timed fallback line while no model chunk has arrived. */
8
+ export interface ThinkingPhase {
9
+ /** Elapsed seconds at which this line becomes current. */
10
+ atSec: number
11
+ /** Status text shown in the stream. */
12
+ text: string
13
+ }
14
+
15
+ /** Stream-animation copy and timing. */
16
+ export interface ThinkingConfig {
17
+ /** Timed fallback lines while no `assistant/chunk` has arrived. */
18
+ phases: ThinkingPhase[]
19
+ /** Idle-phase spinner glyphs. */
20
+ spin: string[]
21
+ /** Extra lines after {@link ThinkingConfig.eggAfterSec}. */
22
+ eggs: string[]
23
+ /** Seconds after which eggs start rotating. */
24
+ eggAfterSec: number
25
+ /** Stream refresh interval. */
26
+ intervalMs: number
27
+ /** Prefix before a friendly tool name. */
28
+ activityPrefix: string
29
+ /** Lines rotated during `reasoning-delta`. */
30
+ reasoningStatus: string[]
31
+ /** Lines rotated during `text-delta`. */
32
+ outputStatus: string[]
33
+ /** Spinner glyphs during reasoning. */
34
+ reasoningSpin: string[]
35
+ /** Spinner glyphs during output. */
36
+ outputSpin: string[]
37
+ /** Tool registration name → WeCom-visible label. */
38
+ toolLabels: Record<string, string>
39
+ }
40
+
41
+ /** Default stream-animation copy; Config / cordis patch may override. */
42
+ export const DEFAULT_THINKING: ThinkingConfig = {
43
+ phases: [
44
+ { atSec: 0, text: '🤔 正在理解你的需求…' },
45
+ { atSec: 8, text: '📋 正在整理任务清单' },
46
+ { atSec: 25, text: '🔍 正在查找相关资料' },
47
+ { atSec: 55, text: '✍️ 正在处理文档/数据' },
48
+ { atSec: 120, text: '🧠 正在思考最佳方案…' },
49
+ { atSec: 240, text: '⏳ 任务较繁琐,请稍候…' },
50
+ { atSec: 420, text: '☕ 快好了,正在收尾…' },
51
+ ],
52
+ spin: ['🧠', '💭', '✨', '🔎', '⚡'],
53
+ eggs: [
54
+ '📎 顺手把要点整理好了,稍后一起给你',
55
+ '📶 网络有点忙,让它慢慢跑',
56
+ '🎯 结果快出来了,坚持一下',
57
+ '🗂️ 资料较多,正在汇总中',
58
+ '🌙 别盯着了,完成会自动通知你',
59
+ ],
60
+ eggAfterSec: 240,
61
+ intervalMs: 1500,
62
+ activityPrefix: '🛠️ 正在执行 ',
63
+ reasoningStatus: ['💭 模型思考中…', '🧠 深入分析中…', '✨ 梳理思路中…'],
64
+ outputStatus: ['✍️ 正在输出回复…', '📝 组织文字中…', '💬 生成回答中…'],
65
+ reasoningSpin: ['💭', '🧠', '🌀', '✨'],
66
+ outputSpin: ['✍️', '📝', '💬', '⚡'],
67
+ toolLabels: {
68
+ pwsh: 'PowerShell',
69
+ bash: 'Shell',
70
+ read_file: '读文件',
71
+ read: '读文件',
72
+ write_file: '写文件',
73
+ write: '写文件',
74
+ edit_file: '编辑文件',
75
+ str_replace: '编辑文件',
76
+ glob: '查找文件',
77
+ grep: '搜索内容',
78
+ web_search: '网页搜索',
79
+ web_fetch: '抓取网页',
80
+ todo_write: '更新待办',
81
+ },
82
+ }
83
+
84
+ /** WeCom client methods used by the animation and final send. */
85
+ export interface WecomStreamClient {
86
+ replyStream(frame: unknown, streamId: string, content: string, finish: boolean): Promise<unknown>
87
+ }
88
+
89
+ /** Map a tool registration name to the WeCom-visible label. */
90
+ export function labelTool(name: string, thinking?: Partial<ThinkingConfig>): string {
91
+ const labels = {
92
+ ...DEFAULT_THINKING.toolLabels,
93
+ ...(thinking?.toolLabels && typeof thinking.toolLabels === 'object' ? thinking.toolLabels : {}),
94
+ }
95
+ return labels[name] || name
96
+ }
97
+
98
+ /** Pick one status line from a configured list, rotating by tick. */
99
+ export function pickStatusLine(
100
+ value: string | string[] | undefined,
101
+ fallback: string[],
102
+ tick: number,
103
+ ): string {
104
+ const list = Array.isArray(value) && value.length > 0
105
+ ? value
106
+ : (typeof value === 'string' && value !== '' ? [value] : fallback)
107
+ return list[Math.abs(tick) % list.length] ?? fallback[0] ?? ''
108
+ }
109
+
110
+ /** Infer the model stream phase from one `assistant/chunk` payload. */
111
+ export function streamPhaseFromChunk(
112
+ chunk: { type?: string; blockType?: string } | undefined,
113
+ ): 'reasoning' | 'outputting' | null {
114
+ if (!chunk || typeof chunk !== 'object') return null
115
+ if (chunk.type === 'reasoning-delta') return 'reasoning'
116
+ if (chunk.type === 'text-delta') return 'outputting'
117
+ if (chunk.type === 'block-start') {
118
+ if (chunk.blockType === 'reasoning') return 'reasoning'
119
+ if (chunk.blockType === 'text') return 'outputting'
120
+ }
121
+ return null
122
+ }
123
+
124
+ /** Format milliseconds as a short Chinese duration. */
125
+ export function fmtDuration(ms: number): string {
126
+ const s = Math.floor(ms / 1000)
127
+ if (s < 60) return `${s} 秒`
128
+ const m = Math.floor(s / 60)
129
+ const r = s % 60
130
+ return r > 0 ? `${m} 分 ${r} 秒` : `${m} 分钟`
131
+ }
132
+
133
+ /** Speed label from elapsed milliseconds. */
134
+ export function speedOf(ms: number): string {
135
+ if (ms < 60000) return '⚡ 神速'
136
+ if (ms < 180000) return '🚀 正常速度'
137
+ return '🐢 耗时较长'
138
+ }
139
+
140
+ /** Footer appended to a completed WeCom reply. */
141
+ export function footerOf(ms: number): string {
142
+ if (ms >= 180000) {
143
+ return `\n\n---\n✅ 执行完成 · 🐢 耗时较长(${fmtDuration(ms)})\n💡 如需提速,可让我把诊断步骤合并成更少的 SSH 批次`
144
+ }
145
+ return `\n\n---\n✅ 执行完成 · ${speedOf(ms)}(${fmtDuration(ms)})`
146
+ }
147
+
148
+ /** Truncate a string to at most `max` UTF-8 bytes. */
149
+ export function truncate(text: string, max: number): string {
150
+ if (Buffer.byteLength(text, 'utf8') <= max) return text
151
+ let t = text
152
+ while (Buffer.byteLength(t, 'utf8') > max) t = t.slice(0, -100)
153
+ return `${t}\n\n...(内容过长已截断)`
154
+ }
155
+
156
+ /**
157
+ * Refresh one stream message until the caller stops it.
158
+ * @param activity - live tool/status text; empty falls back to timed phases.
159
+ * @param thinking - animation copy; defaults to {@link DEFAULT_THINKING}.
160
+ * @param getStreamPhase - model stream phase; selects the spinner pool.
161
+ * @returns disposer that cancels the interval.
162
+ */
163
+ export function startThinking(
164
+ ws: WecomStreamClient,
165
+ frame: unknown,
166
+ streamId: string,
167
+ startedAt: number,
168
+ timeoutSec: number,
169
+ activity?: () => string,
170
+ thinking?: Partial<ThinkingConfig>,
171
+ getStreamPhase?: () => 'idle' | 'reasoning' | 'outputting' | string,
172
+ ): () => void {
173
+ const t = { ...DEFAULT_THINKING, ...thinking }
174
+ const phases = Array.isArray(t.phases) && t.phases.length > 0 ? t.phases : DEFAULT_THINKING.phases
175
+ const spin = Array.isArray(t.spin) && t.spin.length > 0 ? t.spin : DEFAULT_THINKING.spin
176
+ const reasoningSpin = Array.isArray(t.reasoningSpin) && t.reasoningSpin.length > 0
177
+ ? t.reasoningSpin
178
+ : DEFAULT_THINKING.reasoningSpin
179
+ const outputSpin = Array.isArray(t.outputSpin) && t.outputSpin.length > 0
180
+ ? t.outputSpin
181
+ : DEFAULT_THINKING.outputSpin
182
+ const eggs = Array.isArray(t.eggs) && t.eggs.length > 0 ? t.eggs : DEFAULT_THINKING.eggs
183
+ const eggAfterSec = Number.isFinite(t.eggAfterSec) ? t.eggAfterSec : DEFAULT_THINKING.eggAfterSec
184
+ const intervalMs = Number.isFinite(t.intervalMs) && t.intervalMs > 0 ? t.intervalMs : DEFAULT_THINKING.intervalMs
185
+ const total = Number.isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec : 600
186
+ let i = 0
187
+ const timer = setInterval(() => {
188
+ const secs = Math.floor((Date.now() - startedAt) / 1000)
189
+ const live = activity ? activity() : ''
190
+ let stage = phases[0]?.text ?? ''
191
+ if (!live) {
192
+ for (const phase of phases) {
193
+ if (secs >= phase.atSec) stage = phase.text
194
+ }
195
+ }
196
+ const pct = Math.min(Math.floor((secs / total) * 100), 99)
197
+ const filled = '█'.repeat(Math.floor(pct / 10))
198
+ const bar = secs < 3 ? '' : `\n${filled}${'░'.repeat(10 - filled.length)} ${String(pct).padStart(2)}%`
199
+ const remain = Math.max(total - secs, 0)
200
+ const remainTxt = secs < 3 ? '' : ` · 预计还剩 ${Math.floor(remain / 60)}分${remain % 60}秒`
201
+ const egg = secs >= eggAfterSec && eggs.length > 0
202
+ ? `\n${eggs[Math.floor(secs / 60) % eggs.length]}`
203
+ : ''
204
+ const phase = getStreamPhase ? getStreamPhase() : 'idle'
205
+ const emojiPool = phase === 'reasoning'
206
+ ? reasoningSpin
207
+ : phase === 'outputting'
208
+ ? outputSpin
209
+ : spin
210
+ const emoji = emojiPool[i % emojiPool.length]
211
+ i++
212
+ const status = live || stage
213
+ void ws.replyStream(frame, streamId, `${emoji} ${status} ⏱ ${secs} 秒${remainTxt}${bar}${egg}`, false)
214
+ .catch(() => {})
215
+ }, intervalMs)
216
+ return () => clearInterval(timer)
217
+ }
218
+
219
+ /** Finish the current stream; open a new stream if WeCom expired the first. */
220
+ export async function sendFinal(
221
+ ws: WecomStreamClient,
222
+ frame: unknown,
223
+ streamId: string,
224
+ content: string,
225
+ ): Promise<void> {
226
+ try {
227
+ await ws.replyStream(frame, streamId, content, true)
228
+ } catch (error) {
229
+ const message = error instanceof Error ? error.message : String(error)
230
+ console.error(`[im-bridge] 原流最终回复失败(${message}), 尝试新流...`)
231
+ const { generateReqId } = await import('@wecom/aibot-node-sdk')
232
+ await ws.replyStream(frame, generateReqId('stream'), content, true)
233
+ }
234
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true,
10
+ "verbatimModuleSyntax": true,
11
+ "allowImportingTsExtensions": true
12
+ },
13
+ "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"]
14
+ }
@@ -0,0 +1,109 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { basename, dirname, isAbsolute, resolve as resolvePath } from 'node:path'
3
+ import { transform } from 'lightningcss'
4
+ import type { UserConfig } from 'tsdown'
5
+
6
+ const ID = '@mhfire/dsh-im-bridge'
7
+
8
+ /**
9
+ * Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline.
10
+ * The suffix matters: tsdown's guard matches ids ending in `.css`.
11
+ */
12
+ const CSS_VIRTUAL_PREFIX = '\0dsh-css:'
13
+ const CSS_VIRTUAL_SUFFIX = '.mjs'
14
+
15
+ /** Specifiers answered by the browser module table; must stay external. */
16
+ const EXTERNALS = new Set([
17
+ 'react',
18
+ 'react/jsx-runtime',
19
+ 'react-dom',
20
+ 'react-dom/client',
21
+ '@deepseek-ai/cordis',
22
+ '@deepseek-ai/dsh-client-ui-slots',
23
+ '@deepseek-ai/dsh-client-ui-primitives',
24
+ '@deepseek-ai/dsh-client-runtime/client',
25
+ ])
26
+
27
+ const isRequested = (specifier: string): boolean => EXTERNALS.has(specifier)
28
+
29
+ /** Emit one plugin-owned style injector and a CSS Modules class map. */
30
+ function styleInjectionModule(
31
+ id: string,
32
+ fileId: string,
33
+ css: string,
34
+ classMap: Readonly<Record<string, string>>,
35
+ ): string {
36
+ const source = [
37
+ `const css = ${JSON.stringify(css)};`,
38
+ `const tagId = ${JSON.stringify(`${id}/${basename(fileId)}`)};`,
39
+ 'if (typeof document !== \'undefined\' && document.querySelector(\'style[data-plugin-css=\' + JSON.stringify(tagId) + \']\') === null) {',
40
+ ' const tag = document.createElement(\'style\');',
41
+ ` tag.dataset.plugin = ${JSON.stringify(id)};`,
42
+ ' tag.dataset.pluginCss = tagId;',
43
+ ' tag.textContent = css;',
44
+ ' document.head.appendChild(tag);',
45
+ '}',
46
+ `export default ${JSON.stringify(classMap)};`,
47
+ ]
48
+ return source.join('\n')
49
+ }
50
+
51
+ /** Resolve a stylesheet next to its importer. */
52
+ function sourceAssetPath(source: string, importer: string): string {
53
+ if (isAbsolute(source)) return source
54
+ return resolvePath(dirname(importer), source)
55
+ }
56
+
57
+ /** Lazy-CJS factory served as exports["./client"]. */
58
+ const config: UserConfig = {
59
+ name: `${ID}/client`,
60
+ entry: { client: 'src/client/index.ts' },
61
+ outDir: 'lib',
62
+ format: 'cjs',
63
+ platform: 'browser',
64
+ dts: false,
65
+ sourcemap: true,
66
+ clean: false,
67
+ deps: {
68
+ neverBundle: isRequested,
69
+ alwaysBundle: specifier => !isRequested(specifier),
70
+ },
71
+ define: {
72
+ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
73
+ 'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
74
+ 'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
75
+ },
76
+ plugins: [{
77
+ name: 'dsh-css-modules-inline',
78
+ resolveId(source: string, importer: string | undefined) {
79
+ if (!source.endsWith('.module.css')) return null
80
+ const abs = importer !== undefined ? sourceAssetPath(source, importer) : source
81
+ return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
82
+ },
83
+ async load(virtualId: string) {
84
+ if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
85
+ const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
86
+ this.addWatchFile(fileId)
87
+ const source = await readFile(fileId)
88
+ const { code, exports: cssExports } = transform({
89
+ filename: fileId,
90
+ code: source,
91
+ cssModules: { pattern: '[hash]_[local]' },
92
+ minify: true,
93
+ })
94
+ const classMap: Record<string, string> = {}
95
+ const exportEntries = Object.entries(cssExports ?? {})
96
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
97
+ for (const [local, exp] of exportEntries) classMap[local] = exp.name
98
+ return styleInjectionModule(ID, fileId, code.toString(), classMap)
99
+ },
100
+ }],
101
+ outputOptions: {
102
+ entryFileNames: 'client.js',
103
+ banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(ID)}, factory: (require) => {`,
104
+ footer: 'return module.exports; } });',
105
+ intro: 'var module = { exports: {} }; var exports = module.exports;',
106
+ },
107
+ }
108
+
109
+ export default config
package/tsdown.host.ts ADDED
@@ -0,0 +1,36 @@
1
+ import type { UserConfig } from 'tsdown'
2
+
3
+ const HOST_EXTERNAL = [
4
+ '@deepseek-ai/cordis',
5
+ '@deepseek-ai/schemastery',
6
+ '@deepseek-ai/dsh-llm',
7
+ '@deepseek-ai/dsh-session',
8
+ '@deepseek-ai/dsh-agent',
9
+ '@deepseek-ai/dsh-settings',
10
+ '@wecom/aibot-node-sdk',
11
+ ]
12
+
13
+ const isExternal = (specifier: string): boolean =>
14
+ HOST_EXTERNAL.some(name => specifier === name || specifier.startsWith(`${name}/`))
15
+
16
+ /** Host ESM library: Loader entry at lib/index.js. */
17
+ const config: UserConfig = {
18
+ name: '@mhfire/dsh-im-bridge',
19
+ entry: { index: 'src/index.ts' },
20
+ outDir: 'lib',
21
+ format: ['esm'],
22
+ platform: 'node',
23
+ target: 'es2022',
24
+ fixedExtension: false,
25
+ dts: false,
26
+ clean: false,
27
+ outputOptions: {
28
+ entryFileNames: 'index.js',
29
+ },
30
+ deps: {
31
+ neverBundle: isExternal,
32
+ alwaysBundle: specifier => !isExternal(specifier),
33
+ },
34
+ }
35
+
36
+ export default config
package/src/index.js DELETED
@@ -1,272 +0,0 @@
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, DEFAULT_THINKING } from './wecom.js'
23
-
24
- /** 稳定插件名 */
25
- export const name = 'im-bridge'
26
-
27
- /** 依赖的核心服务 */
28
- export const inject = ['agents', 'sessions', 'agentDefaultModel']
29
-
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 提供;缺省时跳过企微连线,不阻塞主进程 */
45
- export const Config = z.object({
46
- botId: z.string().default('').role('secret'),
47
- secret: z.string().default('').role('secret'),
48
- /** Agent 的工作目录(会话 cwd) */
49
- workspace: z.string().default(process.cwd()),
50
- /** 允许的发送者 userid 白名单; 空 = 允许所有人 */
51
- allowFrom: z.array(String).default([]),
52
- /** 占位提示语 */
53
- startHint: z.string().default('🧠 正在思考...'),
54
- /** 动画进度条/剩余估算的超时基准 */
55
- agentTimeoutSec: z.number().default(600),
56
- /** Agent 加入的 preset(web profile 下默认 standard) */
57
- agentPreset: z.string().default('standard'),
58
- /** 覆盖默认 persona 的文本(可选; 为空用 preset 自带) */
59
- persona: z.string().default(''),
60
- /** 从文件读取 persona(可选; 优先于 persona 字段, 便于维护长文本) */
61
- personaFile: z.string().default(''),
62
- /** 回复上限(字节) */
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('👋 办公助手已就绪。直接发消息即可,例如查文件、整理文档、查资料或处理日常事务。'),
70
- })
71
-
72
- /** 收集一次 turn 内最后一条 assistant 文本与结束原因 */
73
- function summarize(events, firstSeq) {
74
- let started = false
75
- let text = ''
76
- let reason
77
- for (const event of events) {
78
- if (event.seq < firstSeq) continue
79
- if (event.type === 'turn/start') { started = true; continue }
80
- if (!started) continue
81
- if (event.type === 'assistant/message') {
82
- const joined = event.data.message.content
83
- .filter((block) => block.type === 'text')
84
- .map((block) => block.text)
85
- .join('')
86
- if (joined !== '') text = joined
87
- }
88
- if (event.type === 'turn/end') reason = event.data.reason
89
- }
90
- return { text, reason }
91
- }
92
-
93
- /** 读取 persona: personaFile 优先, 其次 persona 字段 */
94
- function resolvePersona(config) {
95
- if (config.personaFile) {
96
- try {
97
- return readFileSync(config.personaFile, 'utf8')
98
- } catch (e) {
99
- console.error(`[im-bridge] 读取 personaFile 失败: ${e.message}`)
100
- }
101
- }
102
- return config.persona
103
- }
104
-
105
- export function apply(ctx, config) {
106
- const agents = ctx.get('agents')
107
- const sessions = ctx.get('sessions')
108
- const defaultModel = ctx.get('agentDefaultModel')
109
- if (agents === undefined || sessions === undefined || defaultModel === undefined) {
110
- throw new Error('im-bridge: 需要 agents/sessions/agentDefaultModel 服务')
111
- }
112
-
113
- // ── 设置命名空间: 用户可在 GUI 插件配置页 / settings.yaml 覆盖字段(热生效) ──
114
- // 规范做法(与 @deepseek-ai/dsh-settings 的 installSettingsSection 一致):
115
- // 用 ctx.inject(['settings'], cb) 延迟到 settings 服务可用时再注册,
116
- // 避免 apply 时 settings 尚未挂载导致命名空间缺失(GUI 卡片显示"命名空间不可用")。
117
- // 缺凭证时仍注册, 便于在 Settings 补齐后再重启启用企微。
118
- let scope
119
- ctx.inject(['settings'], (sctx) => {
120
- scope = sctx.settings.register('im-bridge', Config, { base: { ...config } })
121
- })
122
- /** 有效配置: 默认值 → cordis patch(base) → 用户 settings.yaml, 热更新 */
123
- const cfg = () => scope?.value ?? config
124
-
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
134
- }
135
-
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
176
- }
177
-
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')
188
-
189
- /** 处理一条消息: 动画 → followup → 汇总 → 简报 */
190
- async function handle(frame, sender, content) {
191
- const st = await ensureAgent(sender)
192
- const startedAt = Date.now()
193
- const streamId = generateReqId('stream')
194
- let stopThinking = null
195
- try {
196
- await ws.replyStream(frame, streamId, cfg().startHint, false)
197
- stopThinking = startThinking(
198
- ws, frame, streamId, startedAt, cfg().agentTimeoutSec,
199
- () => st.lastActivity || '',
200
- cfg().thinking,
201
- )
202
- } catch (e) {
203
- console.error(`[im-bridge] 占位回复失败: ${e.message}`)
204
- }
205
- try {
206
- await st.agent.whenIdle()
207
- const firstSeq = st.agent.session.seq
208
- st.agent.followup(createUserMessage({
209
- content: [{ type: 'text', text: content }],
210
- source: { kind: 'user' },
211
- }))
212
- await st.agent.whenIdle()
213
- await sessions.flush(st.agent.session)
214
- const outcome = summarize(st.agent.session.events, firstSeq)
215
- if (stopThinking) stopThinking()
216
- const ms = Date.now() - startedAt
217
- const reply = truncate(outcome.text || '(agent 无输出)', (cfg().maxReplyBytes || 20000) - 200) + footerOf(ms)
218
- console.log(`[im-bridge] ${sender} 完成 (${Buffer.byteLength(reply, 'utf8')}B, ${fmtDuration(ms)})`)
219
- await sendFinal(ws, frame, streamId, reply)
220
- } catch (e) {
221
- if (stopThinking) stopThinking()
222
- const ms = Date.now() - startedAt
223
- console.error(`[im-bridge] agent 失败: ${e.message}`)
224
- try {
225
- await sendFinal(ws, frame, streamId, `处理失败: ${truncate(e.message, 400)}\n\n---\n❌ 耗时 ${fmtDuration(ms)}`)
226
- } catch (e2) {
227
- console.error(`[im-bridge] 错误回复也失败: ${e2.message}`)
228
- }
229
- }
230
- }
231
-
232
- const ws = new AiBot.WSClient({ botId, secret })
233
-
234
- ws.on('connected', () => console.log('[im-bridge] WebSocket 已连接'))
235
- ws.on('authenticated', () => console.log('[im-bridge] 认证成功, 等待消息...'))
236
- ws.on('disconnected', (r) => console.log(`[im-bridge] 断开: ${r}`))
237
- ws.on('reconnecting', (n) => console.log(`[im-bridge] 第 ${n} 次重连...`))
238
- ws.on('error', (e) => console.error(`[im-bridge] 错误: ${e.message}`))
239
-
240
- ws.on('message.text', (frame) => {
241
- const content = (frame.body?.text?.content || '').trim()
242
- if (!content) return
243
- const sender = frame.body?.sender?.userid || frame.body?.from?.userid || frame.body?.userid || 'unknown'
244
- if (cfg().allowFrom.length > 0 && !cfg().allowFrom.includes(sender)) {
245
- ws.replyStream(frame, generateReqId('stream'), cfg().deniedMessage, true).catch(() => {})
246
- return
247
- }
248
- console.log(`[im-bridge] 收到 from=${sender}: ${content.slice(0, 100)}`)
249
- const st = senders.get(sender) ?? { queue: Promise.resolve() }
250
- senders.set(sender, st)
251
- st.queue = st.queue
252
- .then(() => handle(frame, sender, content))
253
- .catch((e) => console.error(`[im-bridge] 任务异常: ${e.message}`))
254
- })
255
-
256
- ws.on('event.enter_chat', (frame) => {
257
- const sender = frame.body?.from?.userid || 'unknown'
258
- console.log(`[im-bridge] 用户 ${sender} 进入会话`)
259
- ws.replyWelcome(frame, {
260
- msgtype: 'text',
261
- text: { content: cfg().welcomeMessage },
262
- }).catch((e) => console.error(`[im-bridge] 欢迎语失败: ${e.message}`))
263
- })
264
-
265
- ws.connect()
266
-
267
- // 生命周期: dsh 关闭时断开企微连接
268
- ctx.on('dispose', () => {
269
- try { ws.close?.() } catch { /* 已关闭 */ }
270
- })
271
- })()
272
- }