@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/lib/index.js ADDED
@@ -0,0 +1,570 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import z from "@deepseek-ai/schemastery";
6
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
7
+ import { SessionId } from "@deepseek-ai/dsh-session";
8
+ import { installModelSelection } from "@deepseek-ai/dsh-agent";
9
+ import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
10
+ //#region src/wecom.ts
11
+ /** Default stream-animation copy; Config / cordis patch may override. */
12
+ const DEFAULT_THINKING = {
13
+ phases: [
14
+ {
15
+ atSec: 0,
16
+ text: "🤔 正在理解你的需求…"
17
+ },
18
+ {
19
+ atSec: 8,
20
+ text: "📋 正在整理任务清单"
21
+ },
22
+ {
23
+ atSec: 25,
24
+ text: "🔍 正在查找相关资料"
25
+ },
26
+ {
27
+ atSec: 55,
28
+ text: "✍️ 正在处理文档/数据"
29
+ },
30
+ {
31
+ atSec: 120,
32
+ text: "🧠 正在思考最佳方案…"
33
+ },
34
+ {
35
+ atSec: 240,
36
+ text: "⏳ 任务较繁琐,请稍候…"
37
+ },
38
+ {
39
+ atSec: 420,
40
+ text: "☕ 快好了,正在收尾…"
41
+ }
42
+ ],
43
+ spin: [
44
+ "🧠",
45
+ "💭",
46
+ "✨",
47
+ "🔎",
48
+ "⚡"
49
+ ],
50
+ eggs: [
51
+ "📎 顺手把要点整理好了,稍后一起给你",
52
+ "📶 网络有点忙,让它慢慢跑",
53
+ "🎯 结果快出来了,坚持一下",
54
+ "🗂️ 资料较多,正在汇总中",
55
+ "🌙 别盯着了,完成会自动通知你"
56
+ ],
57
+ eggAfterSec: 240,
58
+ intervalMs: 1500,
59
+ activityPrefix: "🛠️ 正在执行 ",
60
+ reasoningStatus: [
61
+ "💭 模型思考中…",
62
+ "🧠 深入分析中…",
63
+ "✨ 梳理思路中…"
64
+ ],
65
+ outputStatus: [
66
+ "✍️ 正在输出回复…",
67
+ "📝 组织文字中…",
68
+ "💬 生成回答中…"
69
+ ],
70
+ reasoningSpin: [
71
+ "💭",
72
+ "🧠",
73
+ "🌀",
74
+ "✨"
75
+ ],
76
+ outputSpin: [
77
+ "✍️",
78
+ "📝",
79
+ "💬",
80
+ "⚡"
81
+ ],
82
+ toolLabels: {
83
+ pwsh: "PowerShell",
84
+ bash: "Shell",
85
+ read_file: "读文件",
86
+ read: "读文件",
87
+ write_file: "写文件",
88
+ write: "写文件",
89
+ edit_file: "编辑文件",
90
+ str_replace: "编辑文件",
91
+ glob: "查找文件",
92
+ grep: "搜索内容",
93
+ web_search: "网页搜索",
94
+ web_fetch: "抓取网页",
95
+ todo_write: "更新待办"
96
+ }
97
+ };
98
+ /** Map a tool registration name to the WeCom-visible label. */
99
+ function labelTool(name, thinking) {
100
+ return {
101
+ ...DEFAULT_THINKING.toolLabels,
102
+ ...thinking?.toolLabels && typeof thinking.toolLabels === "object" ? thinking.toolLabels : {}
103
+ }[name] || name;
104
+ }
105
+ /** Pick one status line from a configured list, rotating by tick. */
106
+ function pickStatusLine(value, fallback, tick) {
107
+ const list = Array.isArray(value) && value.length > 0 ? value : typeof value === "string" && value !== "" ? [value] : fallback;
108
+ return list[Math.abs(tick) % list.length] ?? fallback[0] ?? "";
109
+ }
110
+ /** Infer the model stream phase from one `assistant/chunk` payload. */
111
+ function streamPhaseFromChunk(chunk) {
112
+ if (!chunk || typeof chunk !== "object") return null;
113
+ if (chunk.type === "reasoning-delta") return "reasoning";
114
+ if (chunk.type === "text-delta") return "outputting";
115
+ if (chunk.type === "block-start") {
116
+ if (chunk.blockType === "reasoning") return "reasoning";
117
+ if (chunk.blockType === "text") return "outputting";
118
+ }
119
+ return null;
120
+ }
121
+ /** Format milliseconds as a short Chinese duration. */
122
+ function fmtDuration(ms) {
123
+ const s = Math.floor(ms / 1e3);
124
+ if (s < 60) return `${s} 秒`;
125
+ const m = Math.floor(s / 60);
126
+ const r = s % 60;
127
+ return r > 0 ? `${m} 分 ${r} 秒` : `${m} 分钟`;
128
+ }
129
+ /** Speed label from elapsed milliseconds. */
130
+ function speedOf(ms) {
131
+ if (ms < 6e4) return "⚡ 神速";
132
+ if (ms < 18e4) return "🚀 正常速度";
133
+ return "🐢 耗时较长";
134
+ }
135
+ /** Footer appended to a completed WeCom reply. */
136
+ function footerOf(ms) {
137
+ if (ms >= 18e4) return `\n\n---\n✅ 执行完成 · 🐢 耗时较长(${fmtDuration(ms)})\n💡 如需提速,可让我把诊断步骤合并成更少的 SSH 批次`;
138
+ return `\n\n---\n✅ 执行完成 · ${speedOf(ms)}(${fmtDuration(ms)})`;
139
+ }
140
+ /** Truncate a string to at most `max` UTF-8 bytes. */
141
+ function truncate(text, max) {
142
+ if (Buffer.byteLength(text, "utf8") <= max) return text;
143
+ let t = text;
144
+ while (Buffer.byteLength(t, "utf8") > max) t = t.slice(0, -100);
145
+ return `${t}\n\n...(内容过长已截断)`;
146
+ }
147
+ /**
148
+ * Refresh one stream message until the caller stops it.
149
+ * @param activity - live tool/status text; empty falls back to timed phases.
150
+ * @param thinking - animation copy; defaults to {@link DEFAULT_THINKING}.
151
+ * @param getStreamPhase - model stream phase; selects the spinner pool.
152
+ * @returns disposer that cancels the interval.
153
+ */
154
+ function startThinking(ws, frame, streamId, startedAt, timeoutSec, activity, thinking, getStreamPhase) {
155
+ const t = {
156
+ ...DEFAULT_THINKING,
157
+ ...thinking
158
+ };
159
+ const phases = Array.isArray(t.phases) && t.phases.length > 0 ? t.phases : DEFAULT_THINKING.phases;
160
+ const spin = Array.isArray(t.spin) && t.spin.length > 0 ? t.spin : DEFAULT_THINKING.spin;
161
+ const reasoningSpin = Array.isArray(t.reasoningSpin) && t.reasoningSpin.length > 0 ? t.reasoningSpin : DEFAULT_THINKING.reasoningSpin;
162
+ const outputSpin = Array.isArray(t.outputSpin) && t.outputSpin.length > 0 ? t.outputSpin : DEFAULT_THINKING.outputSpin;
163
+ const eggs = Array.isArray(t.eggs) && t.eggs.length > 0 ? t.eggs : DEFAULT_THINKING.eggs;
164
+ const eggAfterSec = Number.isFinite(t.eggAfterSec) ? t.eggAfterSec : DEFAULT_THINKING.eggAfterSec;
165
+ const intervalMs = Number.isFinite(t.intervalMs) && t.intervalMs > 0 ? t.intervalMs : DEFAULT_THINKING.intervalMs;
166
+ const total = Number.isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec : 600;
167
+ let i = 0;
168
+ const timer = setInterval(() => {
169
+ const secs = Math.floor((Date.now() - startedAt) / 1e3);
170
+ const live = activity ? activity() : "";
171
+ let stage = phases[0]?.text ?? "";
172
+ if (!live) {
173
+ for (const phase of phases) if (secs >= phase.atSec) stage = phase.text;
174
+ }
175
+ const pct = Math.min(Math.floor(secs / total * 100), 99);
176
+ const filled = "█".repeat(Math.floor(pct / 10));
177
+ const bar = secs < 3 ? "" : `\n${filled}${"░".repeat(10 - filled.length)} ${String(pct).padStart(2)}%`;
178
+ const remain = Math.max(total - secs, 0);
179
+ const remainTxt = secs < 3 ? "" : ` · 预计还剩 ${Math.floor(remain / 60)}分${remain % 60}秒`;
180
+ const egg = secs >= eggAfterSec && eggs.length > 0 ? `\n${eggs[Math.floor(secs / 60) % eggs.length]}` : "";
181
+ const phase = getStreamPhase ? getStreamPhase() : "idle";
182
+ const emojiPool = phase === "reasoning" ? reasoningSpin : phase === "outputting" ? outputSpin : spin;
183
+ const emoji = emojiPool[i % emojiPool.length];
184
+ i++;
185
+ const status = live || stage;
186
+ ws.replyStream(frame, streamId, `${emoji} ${status} ⏱ ${secs} 秒${remainTxt}${bar}${egg}`, false).catch(() => {});
187
+ }, intervalMs);
188
+ return () => clearInterval(timer);
189
+ }
190
+ /** Finish the current stream; open a new stream if WeCom expired the first. */
191
+ async function sendFinal(ws, frame, streamId, content) {
192
+ try {
193
+ await ws.replyStream(frame, streamId, content, true);
194
+ } catch (error) {
195
+ const message = error instanceof Error ? error.message : String(error);
196
+ console.error(`[im-bridge] 原流最终回复失败(${message}), 尝试新流...`);
197
+ const { generateReqId } = await import("@wecom/aibot-node-sdk");
198
+ await ws.replyStream(frame, generateReqId("stream"), content, true);
199
+ }
200
+ }
201
+ //#endregion
202
+ //#region src/index.ts
203
+ /**
204
+ * dsh-im-bridge — WeCom AI bot ⇄ DSH Agent host plugin.
205
+ *
206
+ * Function-plugin shape (`name` / `inject` / `Config` / `apply`, no default
207
+ * export). Messages create in-process Agents so per-sender sessions stay on
208
+ * the same Loader tree as the Web GUI. Settings register through
209
+ * `installSettingsSection`; live fields read `source()`, credentials still
210
+ * require a process restart to open the WebSocket.
211
+ */
212
+ /** Package root (persona files live beside package.json). */
213
+ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
214
+ /** Built-in Chinese persona. */
215
+ const DEFAULT_PERSONA_ZH = join(PACKAGE_ROOT, "persona.default.md");
216
+ /** Built-in English persona. */
217
+ const DEFAULT_PERSONA_EN = join(PACKAGE_ROOT, "persona.default.en.md");
218
+ /** Host locale settings namespace (`dsh-client-locale`). */
219
+ const LOCALE_SETTINGS_NS = settingsNamespace("locale");
220
+ /** Settings namespace paired with the browser card. */
221
+ const IM_BRIDGE_NS = settingsNamespace("im-bridge");
222
+ /** Cordis diagnostic name. */
223
+ const name = "im-bridge";
224
+ /** Required host services. */
225
+ const inject = [
226
+ "agents",
227
+ "sessions",
228
+ "agentDefaultModel"
229
+ ];
230
+ const ThinkingPhase = z.object({
231
+ atSec: z.number(),
232
+ text: z.string()
233
+ });
234
+ const ThinkingSchema = z.object({
235
+ phases: z.array(ThinkingPhase).default(DEFAULT_THINKING.phases),
236
+ spin: z.array(String).default(DEFAULT_THINKING.spin),
237
+ eggs: z.array(String).default(DEFAULT_THINKING.eggs),
238
+ eggAfterSec: z.number().default(DEFAULT_THINKING.eggAfterSec),
239
+ intervalMs: z.number().default(DEFAULT_THINKING.intervalMs),
240
+ activityPrefix: z.string().default(DEFAULT_THINKING.activityPrefix),
241
+ toolLabels: z.dict(String).default(DEFAULT_THINKING.toolLabels),
242
+ reasoningStatus: z.array(String).default(DEFAULT_THINKING.reasoningStatus),
243
+ outputStatus: z.array(String).default(DEFAULT_THINKING.outputStatus),
244
+ reasoningSpin: z.array(String).default(DEFAULT_THINKING.reasoningSpin),
245
+ outputSpin: z.array(String).default(DEFAULT_THINKING.outputSpin)
246
+ });
247
+ /** Schemastery schema for the composition entry and settings namespace. */
248
+ const Config = z.object({
249
+ botId: z.string().default("").role("secret"),
250
+ secret: z.string().default("").role("secret"),
251
+ workspace: z.string().default(process.cwd()),
252
+ allowFrom: z.array(String).default([]),
253
+ startHint: z.string().default("🧠 正在思考..."),
254
+ agentTimeoutSec: z.number().default(600),
255
+ agentPreset: z.string().default("standard"),
256
+ provider: z.string().default(""),
257
+ model: z.string().default(""),
258
+ reasoningEffort: z.string().default(""),
259
+ persona: z.string().default(""),
260
+ personaFile: z.string().default(""),
261
+ maxReplyBytes: z.number().default(2e4),
262
+ thinking: ThinkingSchema.default(DEFAULT_THINKING),
263
+ deniedMessage: z.string().default("无权访问本服务"),
264
+ welcomeMessage: z.string().default("👋 办公助手已就绪。直接发消息即可,例如查文件、整理文档、查资料或处理日常事务。")
265
+ });
266
+ /** Join assistant text from one turn starting at `firstSeq`. */
267
+ function summarize(events, firstSeq) {
268
+ let started = false;
269
+ let text = "";
270
+ let reason;
271
+ for (const event of events) {
272
+ if (event.seq < firstSeq) continue;
273
+ if (event.type === "turn/start") {
274
+ started = true;
275
+ continue;
276
+ }
277
+ if (!started) continue;
278
+ if (event.type === "assistant/message") {
279
+ const joined = (event.data.message?.content ?? []).filter((block) => block.type === "text").map((block) => block.text ?? "").join("");
280
+ if (joined !== "") text = joined;
281
+ }
282
+ if (event.type === "turn/end") reason = event.data.reason;
283
+ }
284
+ return {
285
+ text,
286
+ reason
287
+ };
288
+ }
289
+ /** Read Host `locale.preference`; missing or unknown falls back to `zh`. */
290
+ function readLocalePreference(settings) {
291
+ if (settings === void 0) return "zh";
292
+ try {
293
+ const section = settings.get(LOCALE_SETTINGS_NS);
294
+ return (section && typeof section === "object" && "preference" in section ? section.preference : void 0) === "en" ? "en" : "zh";
295
+ } catch {
296
+ return "zh";
297
+ }
298
+ }
299
+ /** Strip leading `#` comment lines from a built-in persona file. */
300
+ function stripLeadingHashComments(text) {
301
+ const lines = text.split(/\r?\n/);
302
+ let i = 0;
303
+ while (i < lines.length && /^\s*#/.test(lines[i] ?? "")) i++;
304
+ while (i < lines.length && (lines[i] ?? "").trim() === "") i++;
305
+ return lines.slice(i).join("\n");
306
+ }
307
+ /** Resolve persona: personaFile → persona string → built-in locale file. */
308
+ function resolvePersona(config, settings) {
309
+ if (config.personaFile) try {
310
+ return readFileSync(config.personaFile, "utf8");
311
+ } catch (error) {
312
+ const message = error instanceof Error ? error.message : String(error);
313
+ console.error(`[im-bridge] 读取 personaFile 失败: ${message}`);
314
+ }
315
+ if (config.persona !== "") return config.persona;
316
+ const file = readLocalePreference(settings) === "en" ? DEFAULT_PERSONA_EN : DEFAULT_PERSONA_ZH;
317
+ try {
318
+ return stripLeadingHashComments(readFileSync(file, "utf8"));
319
+ } catch (error) {
320
+ const message = error instanceof Error ? error.message : String(error);
321
+ console.error(`[im-bridge] 读取默认人设失败: ${message}`);
322
+ return "";
323
+ }
324
+ }
325
+ /**
326
+ * Resolve the model for a new sender session. Both provider and model must be
327
+ * non-empty to override; otherwise fall back to agent-default-model.
328
+ */
329
+ function resolveSelection(config, defaultModel) {
330
+ const provider = config.provider.trim();
331
+ const model = config.model.trim();
332
+ if (provider !== "" && model !== "") {
333
+ const effort = config.reasoningEffort.trim();
334
+ return effort === "" ? {
335
+ provider,
336
+ model
337
+ } : {
338
+ provider,
339
+ model,
340
+ reasoningEffort: effort
341
+ };
342
+ }
343
+ if (provider !== "" || model !== "") console.warn("[im-bridge] provider/model 需同时填写才覆盖企微模型, 已回退 agent-default-model。");
344
+ return defaultModel.currentSelection();
345
+ }
346
+ /**
347
+ * Mount the WeCom bridge: settings namespace, then a deferred WebSocket after Loader settle.
348
+ * @param ctx - host plugin context.
349
+ * @param config - composition entry used as the settings `base` layer.
350
+ */
351
+ function apply(ctx, config) {
352
+ const agents = ctx.get("agents");
353
+ const sessions = ctx.get("sessions");
354
+ const defaultModel = ctx.get("agentDefaultModel");
355
+ if (agents === void 0 || sessions === void 0 || defaultModel === void 0) throw new Error("im-bridge: 需要 agents/sessions/agentDefaultModel 服务");
356
+ let source = () => config;
357
+ let settings;
358
+ installSettingsSection(ctx, IM_BRIDGE_NS, Config, config, {
359
+ setSource: (current) => {
360
+ source = current;
361
+ },
362
+ onChange: () => {}
363
+ });
364
+ ctx.inject(["settings"], (settingsCtx) => {
365
+ settings = settingsCtx.settings;
366
+ settingsCtx.effect(() => () => {
367
+ settings = void 0;
368
+ }, "im-bridge: settings reader");
369
+ });
370
+ const cfg = () => source();
371
+ (async () => {
372
+ await ctx.get("loader")?.await();
373
+ const { botId, secret } = cfg();
374
+ if (!botId || !secret) {
375
+ console.warn("[im-bridge] 跳过启动: 缺少 botId/secret。请在 profile cordis.patch.yml 或 Settings → 插件配置中填写后重启。");
376
+ return;
377
+ }
378
+ const senders = /* @__PURE__ */ new Map();
379
+ async function ensureAgent(sender) {
380
+ let st = senders.get(sender);
381
+ if (st !== void 0 && st.agent !== void 0) return st;
382
+ const sessionId = SessionId(`session-${randomUUID()}`);
383
+ const selection = resolveSelection(cfg(), defaultModel);
384
+ const presets = ctx.get("agentPresets");
385
+ let resolvedId = cfg().agentPreset;
386
+ if (presets !== void 0) resolvedId = (await presets.resolve(cfg().agentPreset)).id;
387
+ const { agent } = await agents.create({
388
+ sessionId,
389
+ meta: {
390
+ cwd: cfg().workspace,
391
+ agentPreset: resolvedId
392
+ },
393
+ agentOptions: {
394
+ provider: selection.provider,
395
+ model: selection.model
396
+ },
397
+ setup: async (agentCtx) => {
398
+ installModelSelection(agentCtx, {
399
+ current: selection,
400
+ assembled: void 0
401
+ });
402
+ if (presets !== void 0) await presets.mount(agentCtx, resolvedId);
403
+ agentCtx.inject(["systemPrompt"], (promptCtx) => {
404
+ promptCtx.systemPrompt.section({
405
+ name: "deployment:persona",
406
+ order: 0,
407
+ text: () => resolvePersona(cfg(), settings)
408
+ });
409
+ });
410
+ }
411
+ });
412
+ st = {
413
+ agent,
414
+ sessionId,
415
+ queue: Promise.resolve(),
416
+ lastActivity: "",
417
+ activityClearAt: 0,
418
+ lastToolByCallId: /* @__PURE__ */ new Map(),
419
+ modelStreamPhase: "idle",
420
+ streamStatusTick: 0
421
+ };
422
+ senders.set(sender, st);
423
+ console.log(`[im-bridge] 为 ${sender} 创建会话 ${sessionId}`);
424
+ return st;
425
+ }
426
+ ctx.on("session/event", (session, event) => {
427
+ const thinking = cfg().thinking;
428
+ const prefix = thinking?.activityPrefix ?? DEFAULT_THINKING.activityPrefix;
429
+ const flashMs = Number.isFinite(thinking?.intervalMs) && thinking.intervalMs > 0 ? thinking.intervalMs : DEFAULT_THINKING.intervalMs;
430
+ for (const st of senders.values()) {
431
+ if (st.sessionId !== session.id) continue;
432
+ if (event.type === "assistant/chunk") {
433
+ const next = streamPhaseFromChunk(event.data.chunk);
434
+ if (next !== null) st.modelStreamPhase = next;
435
+ continue;
436
+ }
437
+ if (event.type === "tool/call") {
438
+ const toolName = event.data.name ?? "";
439
+ const callId = event.data.callId;
440
+ if (callId !== void 0) st.lastToolByCallId.set(callId, toolName);
441
+ st.activityClearAt = 0;
442
+ st.lastActivity = `${prefix}${labelTool(toolName, thinking)}`;
443
+ return;
444
+ }
445
+ if (event.type === "tool/result") {
446
+ const data = event.data;
447
+ const callId = data.message?.source?.callId;
448
+ const rawName = callId !== void 0 && st.lastToolByCallId.get(callId) || [...st.lastToolByCallId.values()].at(-1) || "";
449
+ if (callId !== void 0) st.lastToolByCallId.delete(callId);
450
+ const label = labelTool(rawName || "工具", thinking);
451
+ st.lastActivity = data.error !== void 0 ? `❌ ${label} 失败` : `✅ ${label} 完成`;
452
+ st.activityClearAt = Date.now() + flashMs;
453
+ st.modelStreamPhase = "idle";
454
+ }
455
+ }
456
+ });
457
+ const { default: AiBot, generateReqId } = await import("@wecom/aibot-node-sdk");
458
+ async function handle(frame, sender, content) {
459
+ const st = await ensureAgent(sender);
460
+ const startedAt = Date.now();
461
+ const streamId = generateReqId("stream");
462
+ let stopThinking = null;
463
+ st.lastActivity = "";
464
+ st.activityClearAt = 0;
465
+ st.lastToolByCallId.clear();
466
+ st.modelStreamPhase = "idle";
467
+ st.streamStatusTick = 0;
468
+ try {
469
+ await ws.replyStream(frame, streamId, cfg().startHint, false);
470
+ stopThinking = startThinking(ws, frame, streamId, startedAt, cfg().agentTimeoutSec, () => {
471
+ if (st.activityClearAt > 0 && Date.now() >= st.activityClearAt) {
472
+ st.lastActivity = "";
473
+ st.activityClearAt = 0;
474
+ }
475
+ if (st.lastActivity) return st.lastActivity;
476
+ const thinking = cfg().thinking;
477
+ const tick = st.streamStatusTick++;
478
+ if (st.modelStreamPhase === "reasoning") return pickStatusLine(thinking?.reasoningStatus, DEFAULT_THINKING.reasoningStatus, tick);
479
+ if (st.modelStreamPhase === "outputting") return pickStatusLine(thinking?.outputStatus, DEFAULT_THINKING.outputStatus, tick);
480
+ return "";
481
+ }, cfg().thinking, () => st.lastActivity ? "idle" : st.modelStreamPhase);
482
+ } catch (error) {
483
+ const message = error instanceof Error ? error.message : String(error);
484
+ console.error(`[im-bridge] 占位回复失败: ${message}`);
485
+ }
486
+ try {
487
+ if (st.agent === void 0) throw new Error("im-bridge: sender agent missing");
488
+ await st.agent.whenIdle();
489
+ const firstSeq = st.agent.session.seq;
490
+ st.agent.followup(createUserMessage({
491
+ content: [{
492
+ type: "text",
493
+ text: content
494
+ }],
495
+ source: { kind: "user" }
496
+ }));
497
+ await st.agent.whenIdle();
498
+ await sessions.flush(st.agent.session);
499
+ const outcome = summarize(st.agent.session.events, firstSeq);
500
+ if (stopThinking) stopThinking();
501
+ const ms = Date.now() - startedAt;
502
+ const reply = truncate(outcome.text || "(agent 无输出)", (cfg().maxReplyBytes || 2e4) - 200) + footerOf(ms);
503
+ console.log(`[im-bridge] ${sender} 完成 (${Buffer.byteLength(reply, "utf8")}B, ${fmtDuration(ms)})`);
504
+ await sendFinal(ws, frame, streamId, reply);
505
+ } catch (error) {
506
+ if (stopThinking) stopThinking();
507
+ const ms = Date.now() - startedAt;
508
+ const message = error instanceof Error ? error.message : String(error);
509
+ console.error(`[im-bridge] agent 失败: ${message}`);
510
+ try {
511
+ await sendFinal(ws, frame, streamId, `处理失败: ${truncate(message, 400)}\n\n---\n❌ 耗时 ${fmtDuration(ms)}`);
512
+ } catch (retryError) {
513
+ const retryMessage = retryError instanceof Error ? retryError.message : String(retryError);
514
+ console.error(`[im-bridge] 错误回复也失败: ${retryMessage}`);
515
+ }
516
+ }
517
+ }
518
+ const ws = new AiBot.WSClient({
519
+ botId,
520
+ secret
521
+ });
522
+ ws.on("connected", (() => console.log("[im-bridge] WebSocket 已连接")));
523
+ ws.on("authenticated", (() => console.log("[im-bridge] 认证成功, 等待消息...")));
524
+ ws.on("disconnected", ((reason) => console.log(`[im-bridge] 断开: ${reason}`)));
525
+ ws.on("reconnecting", ((n) => console.log(`[im-bridge] 第 ${n} 次重连...`)));
526
+ ws.on("error", ((error) => console.error(`[im-bridge] 错误: ${error.message}`)));
527
+ ws.on("message.text", ((frame) => {
528
+ const content = (frame.body?.text?.content || "").trim();
529
+ if (!content) return;
530
+ const sender = frame.body?.sender?.userid || frame.body?.from?.userid || frame.body?.userid || "unknown";
531
+ if (cfg().allowFrom.length > 0 && !cfg().allowFrom.includes(sender)) {
532
+ ws.replyStream(frame, generateReqId("stream"), cfg().deniedMessage, true).catch(() => {});
533
+ return;
534
+ }
535
+ console.log(`[im-bridge] 收到 from=${sender}: ${content.slice(0, 100)}`);
536
+ const st = senders.get(sender) ?? {
537
+ queue: Promise.resolve(),
538
+ lastActivity: "",
539
+ activityClearAt: 0,
540
+ lastToolByCallId: /* @__PURE__ */ new Map(),
541
+ modelStreamPhase: "idle",
542
+ streamStatusTick: 0
543
+ };
544
+ senders.set(sender, st);
545
+ st.queue = st.queue.then(() => handle(frame, sender, content)).catch((error) => {
546
+ const message = error instanceof Error ? error.message : String(error);
547
+ console.error(`[im-bridge] 任务异常: ${message}`);
548
+ });
549
+ }));
550
+ ws.on("event.enter_chat", ((frame) => {
551
+ const sender = frame.body?.from?.userid || "unknown";
552
+ console.log(`[im-bridge] 用户 ${sender} 进入会话`);
553
+ ws.replyWelcome(frame, {
554
+ msgtype: "text",
555
+ text: { content: cfg().welcomeMessage }
556
+ }).catch((error) => {
557
+ const message = error instanceof Error ? error.message : String(error);
558
+ console.error(`[im-bridge] 欢迎语失败: ${message}`);
559
+ });
560
+ }));
561
+ ws.connect();
562
+ ctx.on("dispose", () => {
563
+ try {
564
+ ws.close?.();
565
+ } catch {}
566
+ });
567
+ })();
568
+ }
569
+ //#endregion
570
+ export { Config, IM_BRIDGE_NS, apply, inject, name };
package/package.json CHANGED
@@ -1,43 +1,76 @@
1
- {
2
- "name": "@mhfire/dsh-im-bridge",
3
- "version": "0.1.3",
4
- "description": "企业微信智能机器人 ⇄ DeepSeek Harness Agent 桥接插件:进程内创建 Agent(per-sender 持久会话),会话在 GUI 实时可见;含 Settings 插件配置卡片",
5
- "license": "MIT",
6
- "repository": {
7
- "type": "git",
8
- "url": "git+https://github.com/MHfire/dsh-im-bridge.git"
9
- },
10
- "type": "module",
11
- "main": "src/index.js",
12
- "exports": {
13
- ".": "./src/index.js",
14
- "./client": "./lib/client.js",
15
- "./cordis.patch.yml": "./cordis.patch.yml",
16
- "./package.json": "./package.json"
17
- },
18
- "files": [
19
- "src",
20
- "lib",
21
- "cordis.patch.yml",
22
- "persona.example.md",
23
- "README.en.md"
24
- ],
25
- "dsh": {
26
- "bundle": {
27
- "patch": "./cordis.patch.yml"
28
- },
29
- "client": {
30
- "inject": [
31
- "@deepseek-ai/dsh-client-connection",
32
- "@deepseek-ai/dsh-client-locale",
33
- "@deepseek-ai/dsh-client-runtime",
34
- "@deepseek-ai/dsh-client-ui-settings",
35
- "@deepseek-ai/dsh-api-remotes"
36
- ],
37
- "platform": "web"
38
- }
39
- },
40
- "dependencies": {
41
- "@wecom/aibot-node-sdk": "^1.0.7"
42
- }
43
- }
1
+ {
2
+ "name": "@mhfire/dsh-im-bridge",
3
+ "version": "0.2.0",
4
+ "description": "企业微信智能机器人 ⇄ DeepSeek Harness Agent 桥接插件:进程内创建 Agent(per-sender 持久会话),会话在 GUI 实时可见;含 Settings 插件配置卡片",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/MHfire/dsh-im-bridge.git"
9
+ },
10
+ "type": "module",
11
+ "main": "lib/index.js",
12
+ "exports": {
13
+ ".": "./lib/index.js",
14
+ "./client": "./lib/client.js",
15
+ "./cordis.patch.yml": "./cordis.patch.yml",
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "src",
20
+ "lib",
21
+ "tsdown.host.ts",
22
+ "tsdown.client.ts",
23
+ "tsconfig.json",
24
+ "cordis.patch.yml",
25
+ "persona.default.md",
26
+ "persona.default.en.md",
27
+ "persona.example.md",
28
+ "README.md",
29
+ "README.en.md"
30
+ ],
31
+ "scripts": {
32
+ "build": "tsdown --config tsdown.host.ts && tsdown --config tsdown.client.ts",
33
+ "prepare": "tsdown --config tsdown.host.ts && tsdown --config tsdown.client.ts"
34
+ },
35
+ "dsh": {
36
+ "bundle": {
37
+ "patch": "./cordis.patch.yml"
38
+ },
39
+ "client": {
40
+ "inject": [
41
+ "@deepseek-ai/dsh-client-connection",
42
+ "@deepseek-ai/dsh-client-locale",
43
+ "@deepseek-ai/dsh-client-runtime",
44
+ "@deepseek-ai/dsh-client-ui-settings",
45
+ "@deepseek-ai/dsh-client-ui-settings-plugins",
46
+ "@deepseek-ai/dsh-api-remotes"
47
+ ],
48
+ "platform": "web"
49
+ }
50
+ },
51
+ "peerDependencies": {
52
+ "@deepseek-ai/cordis": "*",
53
+ "@deepseek-ai/dsh-agent": "*",
54
+ "@deepseek-ai/dsh-llm": "*",
55
+ "@deepseek-ai/dsh-session": "*",
56
+ "@deepseek-ai/dsh-settings": "*",
57
+ "@deepseek-ai/schemastery": "*"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "@deepseek-ai/cordis": { "optional": true },
61
+ "@deepseek-ai/dsh-agent": { "optional": true },
62
+ "@deepseek-ai/dsh-llm": { "optional": true },
63
+ "@deepseek-ai/dsh-session": { "optional": true },
64
+ "@deepseek-ai/dsh-settings": { "optional": true },
65
+ "@deepseek-ai/schemastery": { "optional": true }
66
+ },
67
+ "dependencies": {
68
+ "@wecom/aibot-node-sdk": "^1.0.7"
69
+ },
70
+ "devDependencies": {
71
+ "@types/react": "~18.3.1",
72
+ "lightningcss": "^1.32.0",
73
+ "react": "^18.2.0",
74
+ "tsdown": "^0.22.2"
75
+ }
76
+ }