@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/index.ts
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-im-bridge — WeCom AI bot ⇄ DSH Agent host plugin.
|
|
3
|
+
*
|
|
4
|
+
* Function-plugin shape (`name` / `inject` / `Config` / `apply`, no default
|
|
5
|
+
* export). Messages create in-process Agents so per-sender sessions stay on
|
|
6
|
+
* the same Loader tree as the Web GUI. Settings register through
|
|
7
|
+
* `installSettingsSection`; live fields read `source()`, credentials still
|
|
8
|
+
* require a process restart to open the WebSocket.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomUUID } from 'node:crypto'
|
|
12
|
+
import { readFileSync } from 'node:fs'
|
|
13
|
+
import { dirname, join } from 'node:path'
|
|
14
|
+
import { fileURLToPath } from 'node:url'
|
|
15
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
16
|
+
import z from '@deepseek-ai/schemastery'
|
|
17
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
18
|
+
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
19
|
+
import { installModelSelection } from '@deepseek-ai/dsh-agent'
|
|
20
|
+
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
21
|
+
import {
|
|
22
|
+
DEFAULT_THINKING,
|
|
23
|
+
footerOf,
|
|
24
|
+
fmtDuration,
|
|
25
|
+
labelTool,
|
|
26
|
+
pickStatusLine,
|
|
27
|
+
sendFinal,
|
|
28
|
+
startThinking,
|
|
29
|
+
streamPhaseFromChunk,
|
|
30
|
+
truncate,
|
|
31
|
+
type ThinkingConfig,
|
|
32
|
+
} from './wecom.ts'
|
|
33
|
+
|
|
34
|
+
/** Package root (persona files live beside package.json). */
|
|
35
|
+
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
36
|
+
/** Built-in Chinese persona. */
|
|
37
|
+
const DEFAULT_PERSONA_ZH = join(PACKAGE_ROOT, 'persona.default.md')
|
|
38
|
+
/** Built-in English persona. */
|
|
39
|
+
const DEFAULT_PERSONA_EN = join(PACKAGE_ROOT, 'persona.default.en.md')
|
|
40
|
+
/** Host locale settings namespace (`dsh-client-locale`). */
|
|
41
|
+
const LOCALE_SETTINGS_NS = settingsNamespace('locale')
|
|
42
|
+
|
|
43
|
+
/** Settings namespace paired with the browser card. */
|
|
44
|
+
export const IM_BRIDGE_NS = settingsNamespace('im-bridge')
|
|
45
|
+
|
|
46
|
+
/** Cordis diagnostic name. */
|
|
47
|
+
export const name = 'im-bridge'
|
|
48
|
+
|
|
49
|
+
/** Required host services. */
|
|
50
|
+
export const inject = ['agents', 'sessions', 'agentDefaultModel']
|
|
51
|
+
|
|
52
|
+
const ThinkingPhase = z.object({
|
|
53
|
+
atSec: z.number(),
|
|
54
|
+
text: z.string(),
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const ThinkingSchema = z.object({
|
|
58
|
+
phases: z.array(ThinkingPhase).default(DEFAULT_THINKING.phases),
|
|
59
|
+
spin: z.array(String).default(DEFAULT_THINKING.spin),
|
|
60
|
+
eggs: z.array(String).default(DEFAULT_THINKING.eggs),
|
|
61
|
+
eggAfterSec: z.number().default(DEFAULT_THINKING.eggAfterSec),
|
|
62
|
+
intervalMs: z.number().default(DEFAULT_THINKING.intervalMs),
|
|
63
|
+
activityPrefix: z.string().default(DEFAULT_THINKING.activityPrefix),
|
|
64
|
+
toolLabels: z.dict(String).default(DEFAULT_THINKING.toolLabels),
|
|
65
|
+
reasoningStatus: z.array(String).default(DEFAULT_THINKING.reasoningStatus),
|
|
66
|
+
outputStatus: z.array(String).default(DEFAULT_THINKING.outputStatus),
|
|
67
|
+
reasoningSpin: z.array(String).default(DEFAULT_THINKING.reasoningSpin),
|
|
68
|
+
outputSpin: z.array(String).default(DEFAULT_THINKING.outputSpin),
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
/** Plugin config: secrets come from the profile patch or Settings. */
|
|
72
|
+
export interface Config {
|
|
73
|
+
botId: string
|
|
74
|
+
secret: string
|
|
75
|
+
workspace: string
|
|
76
|
+
allowFrom: string[]
|
|
77
|
+
startHint: string
|
|
78
|
+
agentTimeoutSec: number
|
|
79
|
+
agentPreset: string
|
|
80
|
+
provider: string
|
|
81
|
+
model: string
|
|
82
|
+
reasoningEffort: string
|
|
83
|
+
persona: string
|
|
84
|
+
personaFile: string
|
|
85
|
+
maxReplyBytes: number
|
|
86
|
+
thinking: ThinkingConfig
|
|
87
|
+
deniedMessage: string
|
|
88
|
+
welcomeMessage: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Schemastery schema for the composition entry and settings namespace. */
|
|
92
|
+
export const Config: z<Config> = z.object({
|
|
93
|
+
botId: z.string().default('').role('secret'),
|
|
94
|
+
secret: z.string().default('').role('secret'),
|
|
95
|
+
workspace: z.string().default(process.cwd()),
|
|
96
|
+
allowFrom: z.array(String).default([]),
|
|
97
|
+
startHint: z.string().default('🧠 正在思考...'),
|
|
98
|
+
agentTimeoutSec: z.number().default(600),
|
|
99
|
+
agentPreset: z.string().default('standard'),
|
|
100
|
+
provider: z.string().default(''),
|
|
101
|
+
model: z.string().default(''),
|
|
102
|
+
reasoningEffort: z.string().default(''),
|
|
103
|
+
persona: z.string().default(''),
|
|
104
|
+
personaFile: z.string().default(''),
|
|
105
|
+
maxReplyBytes: z.number().default(20000),
|
|
106
|
+
thinking: ThinkingSchema.default(DEFAULT_THINKING),
|
|
107
|
+
deniedMessage: z.string().default('无权访问本服务'),
|
|
108
|
+
welcomeMessage: z.string().default('👋 办公助手已就绪。直接发消息即可,例如查文件、整理文档、查资料或处理日常事务。'),
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
interface LoggedEvent {
|
|
112
|
+
seq: number
|
|
113
|
+
type: string
|
|
114
|
+
data: Record<string, unknown>
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface TextBlock {
|
|
118
|
+
type: string
|
|
119
|
+
text?: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface AssistantMessageData {
|
|
123
|
+
message?: { content?: TextBlock[] }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface ToolCallData {
|
|
127
|
+
name?: string
|
|
128
|
+
callId?: string
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface ToolResultData {
|
|
132
|
+
error?: unknown
|
|
133
|
+
message?: { source?: { callId?: string } }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface ChunkData {
|
|
137
|
+
chunk?: { type?: string; blockType?: string }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface LiveAgent {
|
|
141
|
+
whenIdle(): Promise<void>
|
|
142
|
+
followup(message: unknown): void
|
|
143
|
+
session: { seq: number; events: readonly LoggedEvent[] }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface SenderState {
|
|
147
|
+
agent?: LiveAgent
|
|
148
|
+
sessionId?: string
|
|
149
|
+
queue: Promise<unknown>
|
|
150
|
+
lastActivity: string
|
|
151
|
+
activityClearAt: number
|
|
152
|
+
lastToolByCallId: Map<string, string>
|
|
153
|
+
modelStreamPhase: 'idle' | 'reasoning' | 'outputting'
|
|
154
|
+
streamStatusTick: number
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
interface DefaultModel {
|
|
158
|
+
currentSelection(): { provider: string; model: string; reasoningEffort?: string }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface AgentRegistry {
|
|
162
|
+
create(options: {
|
|
163
|
+
sessionId: ReturnType<typeof SessionId>
|
|
164
|
+
meta?: { cwd?: string; agentPreset?: string }
|
|
165
|
+
agentOptions?: { provider: string; model: string }
|
|
166
|
+
setup?: (agentCtx: Context) => void | Promise<void>
|
|
167
|
+
}): Promise<{ agent: LiveAgent }>
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
interface SessionStore {
|
|
171
|
+
flush(session: LiveAgent['session']): Promise<void>
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface AgentPresets {
|
|
175
|
+
resolve(id: string): Promise<{ id: string }>
|
|
176
|
+
mount(agentCtx: Context, id: string): Promise<unknown>
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
interface SettingsReader {
|
|
180
|
+
get(ns: ReturnType<typeof settingsNamespace>): unknown
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface LoaderTree {
|
|
184
|
+
await(): Promise<void>
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface WecomFrame {
|
|
188
|
+
body?: {
|
|
189
|
+
text?: { content?: string }
|
|
190
|
+
sender?: { userid?: string }
|
|
191
|
+
from?: { userid?: string }
|
|
192
|
+
userid?: string
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
interface WecomClient {
|
|
197
|
+
replyStream(frame: unknown, streamId: string, content: string, finish: boolean): Promise<unknown>
|
|
198
|
+
replyWelcome(frame: unknown, payload: { msgtype: string; text: { content: string } }): Promise<unknown>
|
|
199
|
+
connect(): void
|
|
200
|
+
close?(): void
|
|
201
|
+
on(event: string, handler: (...args: never[]) => void): void
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Join assistant text from one turn starting at `firstSeq`. */
|
|
205
|
+
function summarize(events: readonly LoggedEvent[], firstSeq: number): { text: string; reason: unknown } {
|
|
206
|
+
let started = false
|
|
207
|
+
let text = ''
|
|
208
|
+
let reason: unknown
|
|
209
|
+
for (const event of events) {
|
|
210
|
+
if (event.seq < firstSeq) continue
|
|
211
|
+
if (event.type === 'turn/start') { started = true; continue }
|
|
212
|
+
if (!started) continue
|
|
213
|
+
if (event.type === 'assistant/message') {
|
|
214
|
+
const message = (event.data as AssistantMessageData).message
|
|
215
|
+
const joined = (message?.content ?? [])
|
|
216
|
+
.filter((block) => block.type === 'text')
|
|
217
|
+
.map((block) => block.text ?? '')
|
|
218
|
+
.join('')
|
|
219
|
+
if (joined !== '') text = joined
|
|
220
|
+
}
|
|
221
|
+
if (event.type === 'turn/end') reason = event.data.reason
|
|
222
|
+
}
|
|
223
|
+
return { text, reason }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Read Host `locale.preference`; missing or unknown falls back to `zh`. */
|
|
227
|
+
function readLocalePreference(settings: SettingsReader | undefined): 'zh' | 'en' {
|
|
228
|
+
if (settings === undefined) return 'zh'
|
|
229
|
+
try {
|
|
230
|
+
const section = settings.get(LOCALE_SETTINGS_NS)
|
|
231
|
+
const pref = section && typeof section === 'object' && 'preference' in section
|
|
232
|
+
? (section as { preference?: unknown }).preference
|
|
233
|
+
: undefined
|
|
234
|
+
return pref === 'en' ? 'en' : 'zh'
|
|
235
|
+
} catch {
|
|
236
|
+
return 'zh'
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Strip leading `#` comment lines from a built-in persona file. */
|
|
241
|
+
function stripLeadingHashComments(text: string): string {
|
|
242
|
+
const lines = text.split(/\r?\n/)
|
|
243
|
+
let i = 0
|
|
244
|
+
while (i < lines.length && /^\s*#/.test(lines[i] ?? '')) i++
|
|
245
|
+
while (i < lines.length && (lines[i] ?? '').trim() === '') i++
|
|
246
|
+
return lines.slice(i).join('\n')
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Resolve persona: personaFile → persona string → built-in locale file. */
|
|
250
|
+
function resolvePersona(config: Config, settings: SettingsReader | undefined): string {
|
|
251
|
+
if (config.personaFile) {
|
|
252
|
+
try {
|
|
253
|
+
return readFileSync(config.personaFile, 'utf8')
|
|
254
|
+
} catch (error) {
|
|
255
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
256
|
+
console.error(`[im-bridge] 读取 personaFile 失败: ${message}`)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (config.persona !== '') return config.persona
|
|
260
|
+
const file = readLocalePreference(settings) === 'en' ? DEFAULT_PERSONA_EN : DEFAULT_PERSONA_ZH
|
|
261
|
+
try {
|
|
262
|
+
return stripLeadingHashComments(readFileSync(file, 'utf8'))
|
|
263
|
+
} catch (error) {
|
|
264
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
265
|
+
console.error(`[im-bridge] 读取默认人设失败: ${message}`)
|
|
266
|
+
return ''
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Resolve the model for a new sender session. Both provider and model must be
|
|
272
|
+
* non-empty to override; otherwise fall back to agent-default-model.
|
|
273
|
+
*/
|
|
274
|
+
function resolveSelection(
|
|
275
|
+
config: Config,
|
|
276
|
+
defaultModel: DefaultModel,
|
|
277
|
+
): { provider: string; model: string; reasoningEffort?: string } {
|
|
278
|
+
const provider = config.provider.trim()
|
|
279
|
+
const model = config.model.trim()
|
|
280
|
+
if (provider !== '' && model !== '') {
|
|
281
|
+
const effort = config.reasoningEffort.trim()
|
|
282
|
+
return effort === '' ? { provider, model } : { provider, model, reasoningEffort: effort }
|
|
283
|
+
}
|
|
284
|
+
if (provider !== '' || model !== '') {
|
|
285
|
+
console.warn('[im-bridge] provider/model 需同时填写才覆盖企微模型, 已回退 agent-default-model。')
|
|
286
|
+
}
|
|
287
|
+
return defaultModel.currentSelection()
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Mount the WeCom bridge: settings namespace, then a deferred WebSocket after Loader settle.
|
|
292
|
+
* @param ctx - host plugin context.
|
|
293
|
+
* @param config - composition entry used as the settings `base` layer.
|
|
294
|
+
*/
|
|
295
|
+
export function apply(ctx: Context, config: Config): void {
|
|
296
|
+
const agents = ctx.get('agents') as AgentRegistry | undefined
|
|
297
|
+
const sessions = ctx.get('sessions') as SessionStore | undefined
|
|
298
|
+
const defaultModel = ctx.get('agentDefaultModel') as DefaultModel | undefined
|
|
299
|
+
if (agents === undefined || sessions === undefined || defaultModel === undefined) {
|
|
300
|
+
throw new Error('im-bridge: 需要 agents/sessions/agentDefaultModel 服务')
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
let source = (): Config => config
|
|
304
|
+
let settings: SettingsReader | undefined
|
|
305
|
+
installSettingsSection(ctx, IM_BRIDGE_NS, Config, config, {
|
|
306
|
+
setSource: (current) => { source = current },
|
|
307
|
+
onChange: () => {
|
|
308
|
+
// Live fields are read through source() on the next handle/ensureAgent.
|
|
309
|
+
// botId/secret still require a process restart to open the WebSocket.
|
|
310
|
+
},
|
|
311
|
+
})
|
|
312
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
313
|
+
settings = settingsCtx.settings as SettingsReader
|
|
314
|
+
settingsCtx.effect(() => () => { settings = undefined }, 'im-bridge: settings reader')
|
|
315
|
+
})
|
|
316
|
+
const cfg = (): Config => source()
|
|
317
|
+
|
|
318
|
+
void (async () => {
|
|
319
|
+
const loader = ctx.get('loader') as LoaderTree | undefined
|
|
320
|
+
await loader?.await()
|
|
321
|
+
const { botId, secret } = cfg()
|
|
322
|
+
if (!botId || !secret) {
|
|
323
|
+
console.warn(
|
|
324
|
+
'[im-bridge] 跳过启动: 缺少 botId/secret。请在 profile cordis.patch.yml 或 Settings → 插件配置中填写后重启。',
|
|
325
|
+
)
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const senders = new Map<string, SenderState>()
|
|
330
|
+
|
|
331
|
+
async function ensureAgent(sender: string): Promise<SenderState> {
|
|
332
|
+
let st = senders.get(sender)
|
|
333
|
+
if (st !== undefined && st.agent !== undefined) return st
|
|
334
|
+
const sessionId = SessionId(`session-${randomUUID()}`)
|
|
335
|
+
const selection = resolveSelection(cfg(), defaultModel)
|
|
336
|
+
const presets = ctx.get('agentPresets') as AgentPresets | undefined
|
|
337
|
+
let resolvedId = cfg().agentPreset
|
|
338
|
+
if (presets !== undefined) {
|
|
339
|
+
resolvedId = (await presets.resolve(cfg().agentPreset)).id
|
|
340
|
+
}
|
|
341
|
+
const { agent } = await agents.create({
|
|
342
|
+
sessionId,
|
|
343
|
+
meta: { cwd: cfg().workspace, agentPreset: resolvedId },
|
|
344
|
+
agentOptions: { provider: selection.provider, model: selection.model },
|
|
345
|
+
setup: async (agentCtx) => {
|
|
346
|
+
const selected = { current: selection, assembled: undefined }
|
|
347
|
+
installModelSelection(agentCtx, selected)
|
|
348
|
+
if (presets !== undefined) await presets.mount(agentCtx, resolvedId)
|
|
349
|
+
agentCtx.inject(['systemPrompt'], (promptCtx) => {
|
|
350
|
+
promptCtx.systemPrompt.section({
|
|
351
|
+
name: 'deployment:persona',
|
|
352
|
+
order: 0,
|
|
353
|
+
text: () => resolvePersona(cfg(), settings),
|
|
354
|
+
})
|
|
355
|
+
})
|
|
356
|
+
},
|
|
357
|
+
})
|
|
358
|
+
st = {
|
|
359
|
+
agent,
|
|
360
|
+
sessionId,
|
|
361
|
+
queue: Promise.resolve(),
|
|
362
|
+
lastActivity: '',
|
|
363
|
+
activityClearAt: 0,
|
|
364
|
+
lastToolByCallId: new Map(),
|
|
365
|
+
modelStreamPhase: 'idle',
|
|
366
|
+
streamStatusTick: 0,
|
|
367
|
+
}
|
|
368
|
+
senders.set(sender, st)
|
|
369
|
+
console.log(`[im-bridge] 为 ${sender} 创建会话 ${sessionId}`)
|
|
370
|
+
return st
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
ctx.on('session/event', (session: { id: string }, event: LoggedEvent) => {
|
|
374
|
+
const thinking = cfg().thinking
|
|
375
|
+
const prefix = thinking?.activityPrefix ?? DEFAULT_THINKING.activityPrefix
|
|
376
|
+
const flashMs = Number.isFinite(thinking?.intervalMs) && thinking.intervalMs > 0
|
|
377
|
+
? thinking.intervalMs
|
|
378
|
+
: DEFAULT_THINKING.intervalMs
|
|
379
|
+
for (const st of senders.values()) {
|
|
380
|
+
if (st.sessionId !== session.id) continue
|
|
381
|
+
if (event.type === 'assistant/chunk') {
|
|
382
|
+
const next = streamPhaseFromChunk((event.data as ChunkData).chunk)
|
|
383
|
+
if (next !== null) st.modelStreamPhase = next
|
|
384
|
+
continue
|
|
385
|
+
}
|
|
386
|
+
if (event.type === 'tool/call') {
|
|
387
|
+
const toolName = (event.data as ToolCallData).name ?? ''
|
|
388
|
+
const callId = (event.data as ToolCallData).callId
|
|
389
|
+
if (callId !== undefined) st.lastToolByCallId.set(callId, toolName)
|
|
390
|
+
st.activityClearAt = 0
|
|
391
|
+
st.lastActivity = `${prefix}${labelTool(toolName, thinking)}`
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
if (event.type === 'tool/result') {
|
|
395
|
+
const data = event.data as ToolResultData
|
|
396
|
+
const callId = data.message?.source?.callId
|
|
397
|
+
const rawName = (callId !== undefined && st.lastToolByCallId.get(callId))
|
|
398
|
+
|| [...st.lastToolByCallId.values()].at(-1)
|
|
399
|
+
|| ''
|
|
400
|
+
if (callId !== undefined) st.lastToolByCallId.delete(callId)
|
|
401
|
+
const label = labelTool(rawName || '工具', thinking)
|
|
402
|
+
const failed = data.error !== undefined
|
|
403
|
+
st.lastActivity = failed ? `❌ ${label} 失败` : `✅ ${label} 完成`
|
|
404
|
+
st.activityClearAt = Date.now() + flashMs
|
|
405
|
+
st.modelStreamPhase = 'idle'
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
const { default: AiBot, generateReqId } = await import('@wecom/aibot-node-sdk') as {
|
|
411
|
+
default: { WSClient: new (options: { botId: string; secret: string }) => WecomClient }
|
|
412
|
+
generateReqId: (kind: string) => string
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function handle(frame: WecomFrame, sender: string, content: string): Promise<void> {
|
|
416
|
+
const st = await ensureAgent(sender)
|
|
417
|
+
const startedAt = Date.now()
|
|
418
|
+
const streamId = generateReqId('stream')
|
|
419
|
+
let stopThinking: (() => void) | null = null
|
|
420
|
+
st.lastActivity = ''
|
|
421
|
+
st.activityClearAt = 0
|
|
422
|
+
st.lastToolByCallId.clear()
|
|
423
|
+
st.modelStreamPhase = 'idle'
|
|
424
|
+
st.streamStatusTick = 0
|
|
425
|
+
try {
|
|
426
|
+
await ws.replyStream(frame, streamId, cfg().startHint, false)
|
|
427
|
+
stopThinking = startThinking(
|
|
428
|
+
ws, frame, streamId, startedAt, cfg().agentTimeoutSec,
|
|
429
|
+
() => {
|
|
430
|
+
if (st.activityClearAt > 0 && Date.now() >= st.activityClearAt) {
|
|
431
|
+
st.lastActivity = ''
|
|
432
|
+
st.activityClearAt = 0
|
|
433
|
+
}
|
|
434
|
+
if (st.lastActivity) return st.lastActivity
|
|
435
|
+
const thinking = cfg().thinking
|
|
436
|
+
const tick = st.streamStatusTick++
|
|
437
|
+
if (st.modelStreamPhase === 'reasoning') {
|
|
438
|
+
return pickStatusLine(
|
|
439
|
+
thinking?.reasoningStatus,
|
|
440
|
+
DEFAULT_THINKING.reasoningStatus,
|
|
441
|
+
tick,
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
if (st.modelStreamPhase === 'outputting') {
|
|
445
|
+
return pickStatusLine(
|
|
446
|
+
thinking?.outputStatus,
|
|
447
|
+
DEFAULT_THINKING.outputStatus,
|
|
448
|
+
tick,
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
return ''
|
|
452
|
+
},
|
|
453
|
+
cfg().thinking,
|
|
454
|
+
() => (st.lastActivity ? 'idle' : st.modelStreamPhase),
|
|
455
|
+
)
|
|
456
|
+
} catch (error) {
|
|
457
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
458
|
+
console.error(`[im-bridge] 占位回复失败: ${message}`)
|
|
459
|
+
}
|
|
460
|
+
try {
|
|
461
|
+
if (st.agent === undefined) throw new Error('im-bridge: sender agent missing')
|
|
462
|
+
await st.agent.whenIdle()
|
|
463
|
+
const firstSeq = st.agent.session.seq
|
|
464
|
+
st.agent.followup(createUserMessage({
|
|
465
|
+
content: [{ type: 'text', text: content }],
|
|
466
|
+
source: { kind: 'user' },
|
|
467
|
+
}))
|
|
468
|
+
await st.agent.whenIdle()
|
|
469
|
+
await sessions.flush(st.agent.session)
|
|
470
|
+
const outcome = summarize(st.agent.session.events, firstSeq)
|
|
471
|
+
if (stopThinking) stopThinking()
|
|
472
|
+
const ms = Date.now() - startedAt
|
|
473
|
+
const reply = truncate(outcome.text || '(agent 无输出)', (cfg().maxReplyBytes || 20000) - 200) + footerOf(ms)
|
|
474
|
+
console.log(`[im-bridge] ${sender} 完成 (${Buffer.byteLength(reply, 'utf8')}B, ${fmtDuration(ms)})`)
|
|
475
|
+
await sendFinal(ws, frame, streamId, reply)
|
|
476
|
+
} catch (error) {
|
|
477
|
+
if (stopThinking) stopThinking()
|
|
478
|
+
const ms = Date.now() - startedAt
|
|
479
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
480
|
+
console.error(`[im-bridge] agent 失败: ${message}`)
|
|
481
|
+
try {
|
|
482
|
+
await sendFinal(ws, frame, streamId, `处理失败: ${truncate(message, 400)}\n\n---\n❌ 耗时 ${fmtDuration(ms)}`)
|
|
483
|
+
} catch (retryError) {
|
|
484
|
+
const retryMessage = retryError instanceof Error ? retryError.message : String(retryError)
|
|
485
|
+
console.error(`[im-bridge] 错误回复也失败: ${retryMessage}`)
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const ws = new AiBot.WSClient({ botId, secret })
|
|
491
|
+
|
|
492
|
+
ws.on('connected', (() => console.log('[im-bridge] WebSocket 已连接')) as (...args: never[]) => void)
|
|
493
|
+
ws.on('authenticated', (() => console.log('[im-bridge] 认证成功, 等待消息...')) as (...args: never[]) => void)
|
|
494
|
+
ws.on('disconnected', ((reason: string) => console.log(`[im-bridge] 断开: ${reason}`)) as (...args: never[]) => void)
|
|
495
|
+
ws.on('reconnecting', ((n: number) => console.log(`[im-bridge] 第 ${n} 次重连...`)) as (...args: never[]) => void)
|
|
496
|
+
ws.on('error', ((error: Error) => console.error(`[im-bridge] 错误: ${error.message}`)) as (...args: never[]) => void)
|
|
497
|
+
|
|
498
|
+
ws.on('message.text', ((frame: WecomFrame) => {
|
|
499
|
+
const content = (frame.body?.text?.content || '').trim()
|
|
500
|
+
if (!content) return
|
|
501
|
+
const sender = frame.body?.sender?.userid || frame.body?.from?.userid || frame.body?.userid || 'unknown'
|
|
502
|
+
if (cfg().allowFrom.length > 0 && !cfg().allowFrom.includes(sender)) {
|
|
503
|
+
void ws.replyStream(frame, generateReqId('stream'), cfg().deniedMessage, true).catch(() => {})
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
console.log(`[im-bridge] 收到 from=${sender}: ${content.slice(0, 100)}`)
|
|
507
|
+
const st = senders.get(sender) ?? {
|
|
508
|
+
queue: Promise.resolve(),
|
|
509
|
+
lastActivity: '',
|
|
510
|
+
activityClearAt: 0,
|
|
511
|
+
lastToolByCallId: new Map(),
|
|
512
|
+
modelStreamPhase: 'idle' as const,
|
|
513
|
+
streamStatusTick: 0,
|
|
514
|
+
}
|
|
515
|
+
senders.set(sender, st)
|
|
516
|
+
st.queue = st.queue
|
|
517
|
+
.then(() => handle(frame, sender, content))
|
|
518
|
+
.catch((error: unknown) => {
|
|
519
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
520
|
+
console.error(`[im-bridge] 任务异常: ${message}`)
|
|
521
|
+
})
|
|
522
|
+
}) as (...args: never[]) => void)
|
|
523
|
+
|
|
524
|
+
ws.on('event.enter_chat', ((frame: WecomFrame) => {
|
|
525
|
+
const sender = frame.body?.from?.userid || 'unknown'
|
|
526
|
+
console.log(`[im-bridge] 用户 ${sender} 进入会话`)
|
|
527
|
+
void ws.replyWelcome(frame, {
|
|
528
|
+
msgtype: 'text',
|
|
529
|
+
text: { content: cfg().welcomeMessage },
|
|
530
|
+
}).catch((error: unknown) => {
|
|
531
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
532
|
+
console.error(`[im-bridge] 欢迎语失败: ${message}`)
|
|
533
|
+
})
|
|
534
|
+
}) as (...args: never[]) => void)
|
|
535
|
+
|
|
536
|
+
ws.connect()
|
|
537
|
+
|
|
538
|
+
ctx.on('dispose', () => {
|
|
539
|
+
try { ws.close?.() } catch { /* already closed */ }
|
|
540
|
+
})
|
|
541
|
+
})()
|
|
542
|
+
}
|