@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/README.en.md +114 -82
- package/README.md +114 -82
- package/cordis.patch.yml +83 -47
- package/lib/client.js +789 -153
- package/lib/client.js.map +1 -0
- package/lib/index.js +570 -0
- package/package.json +76 -43
- package/persona.default.en.md +22 -0
- package/persona.default.md +22 -0
- package/src/client/PluginCard.module.css +153 -0
- package/src/client/PluginCard.tsx +83 -0
- package/src/client/WecomCard.tsx +135 -0
- package/src/client/card-controller.ts +146 -0
- package/src/client/card-form.ts +293 -0
- package/src/client/css-modules.d.ts +4 -0
- package/src/client/fields.module.css +124 -0
- package/src/client/fields.tsx +112 -0
- package/src/client/index.ts +37 -0
- package/src/client/locales.ts +112 -0
- package/src/index.ts +542 -0
- package/src/wecom.ts +234 -0
- package/tsconfig.json +14 -0
- package/tsdown.client.ts +109 -0
- package/tsdown.host.ts +36 -0
- package/src/index.js +0 -272
- package/src/wecom.js +0 -111
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
|
+
}
|
package/tsdown.client.ts
ADDED
|
@@ -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
|
-
}
|