@mhfire/dsh-im-bridge 0.1.7 → 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.
@@ -1,12 +1,45 @@
1
1
  /**
2
- * wecom.js 企业微信交互封装: 流式动画(v3) + 最终简报 + 发送助手。
3
- * 逻辑移植自旧版 im-bridge/bridge.js, 改为纯 ESM 供插件使用。
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.
4
5
  */
5
- import { generateReqId } from '@wecom/aibot-node-sdk'
6
6
 
7
- /** 流式思考动画的默认素材(可被 Config.thinking / cordis patch 覆盖) */
8
- export const DEFAULT_THINKING = {
9
- /** 尚无 assistant/chunk 时的时间轴兜底文案(与模型是否在推理无关) */
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 = {
10
43
  phases: [
11
44
  { atSec: 0, text: '🤔 正在理解你的需求…' },
12
45
  { atSec: 8, text: '📋 正在整理任务清单' },
@@ -27,15 +60,10 @@ export const DEFAULT_THINKING = {
27
60
  eggAfterSec: 240,
28
61
  intervalMs: 1500,
29
62
  activityPrefix: '🛠️ 正在执行 ',
30
- /** 收到 reasoning-delta 时的状态行文案(按刷新轮换) */
31
63
  reasoningStatus: ['💭 模型思考中…', '🧠 深入分析中…', '✨ 梳理思路中…'],
32
- /** 收到 text-delta 时的状态行文案(按刷新轮换) */
33
64
  outputStatus: ['✍️ 正在输出回复…', '📝 组织文字中…', '💬 生成回答中…'],
34
- /** 思考阶段专用旋转表情 */
35
65
  reasoningSpin: ['💭', '🧠', '🌀', '✨'],
36
- /** 输出阶段专用旋转表情 */
37
66
  outputSpin: ['✍️', '📝', '💬', '⚡'],
38
- /** 工具名 → 企微活动文案友好名(可被 thinking.toolLabels 覆盖) */
39
67
  toolLabels: {
40
68
  pwsh: 'PowerShell',
41
69
  bash: 'Shell',
@@ -53,13 +81,13 @@ export const DEFAULT_THINKING = {
53
81
  },
54
82
  }
55
83
 
56
- /**
57
- * 将工具注册名映射为企微可见友好名。
58
- * @param {string} name - 工具名(如 pwsh)
59
- * @param {typeof DEFAULT_THINKING | undefined} thinking - 含可选 toolLabels 覆盖
60
- * @returns {string}
61
- */
62
- export function labelTool(name, thinking) {
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 {
63
91
  const labels = {
64
92
  ...DEFAULT_THINKING.toolLabels,
65
93
  ...(thinking?.toolLabels && typeof thinking.toolLabels === 'object' ? thinking.toolLabels : {}),
@@ -67,25 +95,22 @@ export function labelTool(name, thinking) {
67
95
  return labels[name] || name
68
96
  }
69
97
 
70
- /**
71
- * 从 string | string[] 配置中按 tick 取一条状态文案。
72
- * @param {string | string[] | undefined} value
73
- * @param {string[]} fallback
74
- * @param {number} tick
75
- */
76
- export function pickStatusLine(value, fallback, tick) {
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 {
77
104
  const list = Array.isArray(value) && value.length > 0
78
105
  ? value
79
106
  : (typeof value === 'string' && value !== '' ? [value] : fallback)
80
- return list[Math.abs(tick) % list.length]
107
+ return list[Math.abs(tick) % list.length] ?? fallback[0] ?? ''
81
108
  }
82
109
 
83
- /**
84
- * 根据 assistant/chunk 判别模型流式阶段。
85
- * @param {{ type?: string, blockType?: string } | undefined} chunk
86
- * @returns {'reasoning' | 'outputting' | null}
87
- */
88
- export function streamPhaseFromChunk(chunk) {
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 {
89
114
  if (!chunk || typeof chunk !== 'object') return null
90
115
  if (chunk.type === 'reasoning-delta') return 'reasoning'
91
116
  if (chunk.type === 'text-delta') return 'outputting'
@@ -96,8 +121,8 @@ export function streamPhaseFromChunk(chunk) {
96
121
  return null
97
122
  }
98
123
 
99
- /** 毫秒 人类可读时长(如 "9 47 秒") */
100
- export function fmtDuration(ms) {
124
+ /** Format milliseconds as a short Chinese duration. */
125
+ export function fmtDuration(ms: number): string {
101
126
  const s = Math.floor(ms / 1000)
102
127
  if (s < 60) return `${s} 秒`
103
128
  const m = Math.floor(s / 60)
@@ -105,38 +130,47 @@ export function fmtDuration(ms) {
105
130
  return r > 0 ? `${m} 分 ${r} 秒` : `${m} 分钟`
106
131
  }
107
132
 
108
- /** 按耗时给个速度评价 */
109
- export function speedOf(ms) {
133
+ /** Speed label from elapsed milliseconds. */
134
+ export function speedOf(ms: number): string {
110
135
  if (ms < 60000) return '⚡ 神速'
111
136
  if (ms < 180000) return '🚀 正常速度'
112
137
  return '🐢 耗时较长'
113
138
  }
114
139
 
115
- /** 执行完成的尾部简报 */
116
- export function footerOf(ms) {
140
+ /** Footer appended to a completed WeCom reply. */
141
+ export function footerOf(ms: number): string {
117
142
  if (ms >= 180000) {
118
143
  return `\n\n---\n✅ 执行完成 · 🐢 耗时较长(${fmtDuration(ms)})\n💡 如需提速,可让我把诊断步骤合并成更少的 SSH 批次`
119
144
  }
120
145
  return `\n\n---\n✅ 执行完成 · ${speedOf(ms)}(${fmtDuration(ms)})`
121
146
  }
122
147
 
123
- /** 截断(按字节) */
124
- export function truncate(text, max) {
148
+ /** Truncate a string to at most `max` UTF-8 bytes. */
149
+ export function truncate(text: string, max: number): string {
125
150
  if (Buffer.byteLength(text, 'utf8') <= max) return text
126
151
  let t = text
127
152
  while (Buffer.byteLength(t, 'utf8') > max) t = t.slice(0, -100)
128
- return t + '\n\n...(内容过长已截断)'
153
+ return `${t}\n\n...(内容过长已截断)`
129
154
  }
130
155
 
131
156
  /**
132
- * 流式动画 v3: 阶段化台词 + 旋转表情 + 进度条 + 已用时/剩余估算 + 长任务彩蛋。
133
- * intervalMs 更新一次同一条流式消息(共用 streamId), 直到 agent 完成。返回停止函数。
134
- * @param {() => string} [activity] - 可选: 返回当前真实活动描述, 覆盖阶段台词。
135
- * @param {typeof DEFAULT_THINKING} [thinking] - 动画素材; 缺省用 {@link DEFAULT_THINKING}。
136
- * @param {() => 'idle' | 'reasoning' | 'outputting' | string} [getStreamPhase] - 模型流式阶段, 影响旋转表情池。
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.
137
162
  */
138
- export function startThinking(ws, frame, streamId, startedAt, timeoutSec, activity, thinking, getStreamPhase) {
139
- const t = { ...DEFAULT_THINKING, ...(thinking || {}) }
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 }
140
174
  const phases = Array.isArray(t.phases) && t.phases.length > 0 ? t.phases : DEFAULT_THINKING.phases
141
175
  const spin = Array.isArray(t.spin) && t.spin.length > 0 ? t.spin : DEFAULT_THINKING.spin
142
176
  const reasoningSpin = Array.isArray(t.reasoningSpin) && t.reasoningSpin.length > 0
@@ -153,10 +187,10 @@ export function startThinking(ws, frame, streamId, startedAt, timeoutSec, activi
153
187
  const timer = setInterval(() => {
154
188
  const secs = Math.floor((Date.now() - startedAt) / 1000)
155
189
  const live = activity ? activity() : ''
156
- let stage = phases[0].text
190
+ let stage = phases[0]?.text ?? ''
157
191
  if (!live) {
158
- for (const p of phases) {
159
- if (secs >= p.atSec) stage = p.text
192
+ for (const phase of phases) {
193
+ if (secs >= phase.atSec) stage = phase.text
160
194
  }
161
195
  }
162
196
  const pct = Math.min(Math.floor((secs / total) * 100), 99)
@@ -176,17 +210,25 @@ export function startThinking(ws, frame, streamId, startedAt, timeoutSec, activi
176
210
  const emoji = emojiPool[i % emojiPool.length]
177
211
  i++
178
212
  const status = live || stage
179
- ws.replyStream(frame, streamId, `${emoji} ${status} ⏱ ${secs} 秒${remainTxt}${bar}${egg}`, false).catch(() => {})
213
+ void ws.replyStream(frame, streamId, `${emoji} ${status} ⏱ ${secs} 秒${remainTxt}${bar}${egg}`, false)
214
+ .catch(() => {})
180
215
  }, intervalMs)
181
216
  return () => clearInterval(timer)
182
217
  }
183
218
 
184
- /** 最终回复: 原流失败(如 WeCom 10 分钟过期)时用新流重试一次 */
185
- export async function sendFinal(ws, frame, streamId, content) {
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> {
186
226
  try {
187
227
  await ws.replyStream(frame, streamId, content, true)
188
- } catch (e) {
189
- console.error(`[im-bridge] 原流最终回复失败(${e.message}), 尝试新流...`)
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')
190
232
  await ws.replyStream(frame, generateReqId('stream'), content, true)
191
233
  }
192
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