opencode-chat-channel 1.2.11 → 1.2.13

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 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/channels/feishu/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,OAAO,KAAK,EAAe,cAAc,EAAiC,MAAM,gBAAgB,CAAC;AAmOjG;;;GAGG;AACH,eAAO,MAAM,oBAAoB,EAAE,cAuBlC,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/channels/feishu/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,OAAO,KAAK,EAAe,cAAc,EAAiC,MAAM,gBAAgB,CAAC;AAoSjG;;;GAGG;AACH,eAAO,MAAM,oBAAoB,EAAE,cAuBlC,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAsVlD,eAAO,MAAM,iBAAiB,EAAE,MAyF/B,CAAC;AAEF,eAAe,iBAAiB,CAAC;AAMjC,YAAY,EAAE,WAAW,EAAE,cAAc,EAAE,WAAW,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAiUlD,eAAO,MAAM,iBAAiB,EAAE,MA8K/B,CAAC;AAEF,eAAe,iBAAiB,CAAC;AAMjC,YAAY,EAAE,WAAW,EAAE,cAAc,EAAE,WAAW,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -2,7 +2,20 @@
2
2
  import { join } from "path";
3
3
 
4
4
  // src/session-manager.ts
5
- import { readFileSync } from "fs";
5
+ import { readFileSync, appendFileSync, mkdirSync } from "fs";
6
+ var LOG_DIR = `${process.env["HOME"] ?? "/tmp"}/.local/share/opencode/log`;
7
+ var LOG_FILE = `${LOG_DIR}/chat-channel.log`;
8
+ function fileLog(level, message) {
9
+ try {
10
+ mkdirSync(LOG_DIR, { recursive: true });
11
+ const ts = new Date().toISOString();
12
+ appendFileSync(LOG_FILE, `${ts} [${level.toUpperCase()}] ${message}
13
+ `, "utf8");
14
+ } catch (err) {
15
+ process.stderr.write(`[chat-channel] fileLog 写入失败: ${String(err)}
16
+ `);
17
+ }
18
+ }
6
19
  var SESSION_TTL_MS = 2 * 60 * 60 * 1000;
7
20
 
8
21
  class SessionManager {
@@ -49,11 +62,6 @@ class SessionManager {
49
62
  return setInterval(() => this.cleanup(), intervalMs);
50
63
  }
51
64
  }
52
- function extractResponseText(parts) {
53
- if (!Array.isArray(parts))
54
- return "";
55
- return parts.filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("").trim();
56
- }
57
65
  function loadDotEnv(envPath) {
58
66
  try {
59
67
  const content = readFileSync(envPath, "utf8");
@@ -150,7 +158,7 @@ class FeishuChannel {
150
158
  }
151
159
  }
152
160
  async sendThinkingCard(chatId) {
153
- const card = buildThinkingCard("⏳ 正在思考...");
161
+ const card = buildCard("⏳ 正在思考...", "", "thinking");
154
162
  try {
155
163
  const res = await this.larkClient.im.message.create({
156
164
  params: { receive_id_type: "chat_id" },
@@ -160,26 +168,63 @@ class FeishuChannel {
160
168
  msg_type: "interactive"
161
169
  }
162
170
  });
163
- return res.data?.message_id ?? null;
171
+ const msgId = res.data?.message_id ?? null;
172
+ fileLog("info", `[feishu] sendThinkingCard 成功: msgId=${msgId}`);
173
+ return msgId;
164
174
  } catch (err) {
175
+ const errStr = err?.response?.data ? JSON.stringify(err.response.data) : String(err);
165
176
  this.client.app.log({
166
177
  body: {
167
178
  service: "chat-channel",
168
179
  level: "warn",
169
- message: `[feishu] 发送思考卡片失败: ${String(err)}`
180
+ message: `[feishu] sendThinkingCard 失败: ${errStr}`
170
181
  }
171
182
  });
183
+ fileLog("warn", `[feishu] sendThinkingCard 失败: ${errStr}`);
172
184
  return null;
173
185
  }
174
186
  }
175
187
  async updateThinkingCard(messageId, statusText) {
176
- const card = buildThinkingCard(statusText);
188
+ const card = buildCard(statusText, "", "thinking");
177
189
  try {
178
190
  await this.larkClient.im.message.patch({
179
191
  data: { content: JSON.stringify(card) },
180
192
  path: { message_id: messageId }
181
193
  });
182
- } catch {}
194
+ fileLog("info", `[feishu] updateThinkingCard 成功: msgId=${messageId}`);
195
+ } catch (err) {
196
+ const errStr = err?.response?.data ? JSON.stringify(err.response.data) : String(err);
197
+ this.client.app.log({ body: { service: "chat-channel", level: "warn", message: `[feishu] updateThinkingCard patch 失败: ${errStr}` } });
198
+ fileLog("warn", `[feishu] updateThinkingCard patch 失败: ${errStr}`);
199
+ }
200
+ }
201
+ async patchProgress(messageId, summary, detail, stage) {
202
+ const card = buildCard(summary, detail, stage);
203
+ try {
204
+ await this.larkClient.im.message.patch({
205
+ data: { content: JSON.stringify(card) },
206
+ path: { message_id: messageId }
207
+ });
208
+ fileLog("info", `[feishu] patchProgress 成功: msgId=${messageId} stage=${stage}`);
209
+ } catch (err) {
210
+ const errStr = err?.response?.data ? JSON.stringify(err.response.data) : String(err);
211
+ this.client.app.log({ body: { service: "chat-channel", level: "warn", message: `[feishu] patchProgress 失败: ${errStr}` } });
212
+ fileLog("warn", `[feishu] patchProgress 失败: ${errStr}`);
213
+ }
214
+ }
215
+ async patchDone(messageId, detail) {
216
+ const card = buildCard("✅ 已完成", detail, "done");
217
+ try {
218
+ await this.larkClient.im.message.patch({
219
+ data: { content: JSON.stringify(card) },
220
+ path: { message_id: messageId }
221
+ });
222
+ fileLog("info", `[feishu] patchDone 成功: msgId=${messageId}`);
223
+ } catch (err) {
224
+ const errStr = err?.response?.data ? JSON.stringify(err.response.data) : String(err);
225
+ this.client.app.log({ body: { service: "chat-channel", level: "warn", message: `[feishu] patchDone 失败: ${errStr}` } });
226
+ fileLog("warn", `[feishu] patchDone 失败: ${errStr}`);
227
+ }
183
228
  }
184
229
  parseEvent(data) {
185
230
  const { message, sender } = data ?? {};
@@ -218,16 +263,25 @@ class FeishuChannel {
218
263
  };
219
264
  }
220
265
  }
221
- function buildThinkingCard(text) {
222
- return {
266
+ function buildCard(summary, detail, stage) {
267
+ const templates = {
268
+ thinking: "blue",
269
+ reasoning: "wathet",
270
+ replying: "turquoise",
271
+ done: "green"
272
+ };
273
+ const card = {
274
+ schema: "2.0",
223
275
  config: { update_multi: true },
224
- elements: [
225
- {
226
- tag: "markdown",
227
- content: text
228
- }
229
- ]
276
+ header: {
277
+ title: { tag: "plain_text", content: summary },
278
+ template: templates[stage]
279
+ }
280
+ };
281
+ card["body"] = {
282
+ elements: detail ? [{ tag: "markdown", content: detail }] : [{ tag: "markdown", content: " " }]
230
283
  };
284
+ return card;
231
285
  }
232
286
  var feishuChannelFactory = async (client) => {
233
287
  const appId = process.env["FEISHU_APP_ID"];
@@ -320,54 +374,90 @@ function resolveEnabledChannels(client) {
320
374
  }
321
375
  return enabled;
322
376
  }
323
- function stripMarkdownTables(text) {
324
- return text.split(`
325
- `).map((line) => {
326
- const trimmed = line.trim();
327
- if (/^\|[-:\s|]+\|$/.test(trimmed))
328
- return "";
329
- if (trimmed.startsWith("|"))
330
- return "[表格内容]";
331
- return line;
332
- }).filter((line, i, arr) => !(line === "[表格内容]" && arr[i - 1] === "[表格内容]")).join(`
333
- `).trim();
377
+ var PATCH_THROTTLE_MS = 2000;
378
+ function buildDetailContent(entry) {
379
+ const parts = [];
380
+ if (entry.reasoning) {
381
+ parts.push(`**\uD83D\uDCAD 推理过程**
382
+ ${entry.reasoning}`);
383
+ }
384
+ for (const callId of entry.toolPartOrder) {
385
+ const t = entry.toolParts.get(callId);
386
+ if (!t)
387
+ continue;
388
+ const statusIcon = t.status === "completed" ? "✓" : t.status === "running" ? "..." : "⏳";
389
+ const inputStr = t.input !== undefined ? `
390
+ input: \`${JSON.stringify(t.input)}\`` : "";
391
+ const outputStr = t.output ? `
392
+ output: ${t.output.length > 200 ? t.output.slice(0, 200) + "..." : t.output}` : "";
393
+ parts.push(`\uD83D\uDD27 **${t.tool}** (${statusIcon})${inputStr}${outputStr}`);
394
+ }
395
+ const allText = entry.textPartOrder.map((id) => entry.textParts.get(id) ?? "").filter(Boolean).join(`
396
+
397
+ `);
398
+ if (allText) {
399
+ parts.push(`**✍️ 回复内容**
400
+ ${allText}`);
401
+ }
402
+ return parts.join(`
403
+
404
+ ---
405
+
406
+ `) || "(暂无内容)";
407
+ }
408
+ function doPatch(entry) {
409
+ if (!entry.thinkingMsgId)
410
+ return;
411
+ entry.lastPatchTime = Date.now();
412
+ const hasText = entry.textPartOrder.some((id) => (entry.textParts.get(id) ?? "").length > 0);
413
+ const stage = hasText ? "replying" : "reasoning";
414
+ const summaryText = hasText ? "✍️ 正在输出回复..." : "\uD83E\uDD14 正在推理...";
415
+ const detail = buildDetailContent(entry);
416
+ entry.channel.patchProgress?.(entry.thinkingMsgId, summaryText, detail, stage);
417
+ }
418
+ function scheduleThrottledPatch(entry) {
419
+ const now = Date.now();
420
+ if (now - entry.lastPatchTime >= PATCH_THROTTLE_MS) {
421
+ doPatch(entry);
422
+ } else if (!entry.pendingPatchTimer) {
423
+ const delay = PATCH_THROTTLE_MS - (now - entry.lastPatchTime);
424
+ entry.pendingPatchTimer = setTimeout(() => {
425
+ entry.pendingPatchTimer = null;
426
+ doPatch(entry);
427
+ }, delay);
428
+ }
334
429
  }
335
- var REASONING_PREVIEW_LEN = 200;
336
- var PATCH_THROTTLE_MS = 3000;
337
- function createMessageHandler(channel, sessionManager, client) {
430
+ function createMessageHandler(channel, sessionManager, client, pendingMap) {
338
431
  return async (msg) => {
339
432
  const { userId, replyTarget, text } = msg;
433
+ const incomingMsg = `[${channel.name}] 收到消息: "${text.slice(0, 80)}${text.length > 80 ? "..." : ""}"`;
434
+ fileLog("info", incomingMsg);
340
435
  await client.app.log({
341
436
  body: {
342
437
  service: "chat-channel",
343
438
  level: "info",
344
- message: `[${channel.name}] 收到消息: "${text.slice(0, 80)}${text.length > 80 ? "..." : ""}"`,
439
+ message: incomingMsg,
345
440
  extra: { userId, replyTarget }
346
441
  }
347
442
  });
348
443
  let thinkingMsgId = null;
349
444
  if (channel.sendThinkingCard) {
350
445
  thinkingMsgId = await channel.sendThinkingCard(replyTarget);
446
+ fileLog("info", `[${channel.name}] sendThinkingCard 完成: thinkingMsgId=${thinkingMsgId}`);
351
447
  }
352
448
  let sessionId;
353
449
  try {
354
450
  sessionId = await sessionManager.getOrCreate(userId);
355
451
  } catch (err) {
356
452
  const errorMsg = err?.message ?? String(err);
453
+ const errMsg1 = `[${channel.name}] 获取 session 失败: ${errorMsg}`;
454
+ fileLog("error", errMsg1);
357
455
  await client.app.log({
358
- body: { service: "chat-channel", level: "error", message: `[${channel.name}] 获取 session 失败: ${errorMsg}`, extra: { userId } }
456
+ body: { service: "chat-channel", level: "error", message: errMsg1, extra: { userId } }
359
457
  });
360
458
  await channel.send(replyTarget, `⚠️ 出错了:${errorMsg}`);
361
459
  return;
362
460
  }
363
- let eventStream = null;
364
- try {
365
- eventStream = await client.event.subscribe();
366
- } catch (err) {
367
- await client.app.log({
368
- body: { service: "chat-channel", level: "warn", message: `[${channel.name}] SSE 订阅失败,降级为轮询: ${String(err)}` }
369
- });
370
- }
371
461
  try {
372
462
  await client.session.promptAsync({
373
463
  path: { id: sessionId },
@@ -375,8 +465,10 @@ function createMessageHandler(channel, sessionManager, client) {
375
465
  parts: [{ type: "text", text }]
376
466
  }
377
467
  });
468
+ fileLog("info", `[${channel.name}] promptAsync 成功: sessionId=${sessionId}`);
378
469
  } catch (err) {
379
470
  const errorMsg = err?.data?.message ?? err?.message ?? String(err);
471
+ fileLog("error", `[${channel.name}] promptAsync 失败: ${errorMsg}`);
380
472
  await client.app.log({
381
473
  body: { service: "chat-channel", level: "error", message: `[${channel.name}] promptAsync 失败: ${errorMsg}`, extra: { userId } }
382
474
  });
@@ -387,122 +479,47 @@ function createMessageHandler(channel, sessionManager, client) {
387
479
  }
388
480
  return;
389
481
  }
390
- await consumeSessionEvents(client, channel, sessionId, replyTarget, thinkingMsgId, eventStream);
482
+ await waitForSessionAndReply(client, channel, sessionId, replyTarget, thinkingMsgId, pendingMap);
391
483
  };
392
484
  }
393
- async function consumeSessionEvents(client, channel, sessionId, replyTarget, thinkingMsgId, eventStream) {
394
- let lastPatchAt = 0;
395
- let reasoningAccum = "";
396
- const log = (level, message) => void client.app.log({ body: { service: "chat-channel", level, message } });
397
- async function throttledPatch(text, force = false) {
398
- if (!thinkingMsgId || !channel.updateThinkingCard)
399
- return;
400
- const now = Date.now();
401
- if (!force && now - lastPatchAt < PATCH_THROTTLE_MS)
402
- return;
403
- lastPatchAt = now;
404
- await channel.updateThinkingCard(thinkingMsgId, text);
405
- }
406
- if (eventStream) {
407
- let alreadyDone = false;
408
- try {
409
- const statusRes = await client.session.status();
410
- const allStatuses = statusRes.data ?? {};
411
- const sessionStatus = allStatuses[sessionId];
412
- if (!sessionStatus || sessionStatus.type === "idle") {
413
- alreadyDone = true;
414
- log("info", `[${channel.name}] session 已完成,跳过 SSE 等待`);
415
- }
416
- } catch {}
417
- if (!alreadyDone) {
418
- const SSE_TIMEOUT_MS = 3 * 60 * 1000;
419
- const abortController = new AbortController;
420
- const timeoutId = setTimeout(() => {
421
- log("warn", `[${channel.name}] SSE 等待超时,强制结束`);
422
- abortController.abort();
423
- }, SSE_TIMEOUT_MS);
424
- try {
425
- for await (const event of eventStream.stream) {
426
- if (abortController.signal.aborted)
427
- break;
428
- if (!isSessionEvent(event, sessionId))
429
- continue;
430
- if (event.type === "message.part.updated") {
431
- const part = event.properties?.part;
432
- if (!part)
433
- continue;
434
- if (part.type === "reasoning" && part.text) {
435
- reasoningAccum = part.text;
436
- const preview = stripMarkdownTables(reasoningAccum.slice(0, REASONING_PREVIEW_LEN));
437
- const suffix = reasoningAccum.length > REASONING_PREVIEW_LEN ? "..." : "";
438
- await throttledPatch(`\uD83D\uDCAD **正在思考...**
439
-
440
- ${preview}${suffix}`);
441
- } else if (part.type === "tool" && part.state?.status === "running") {
442
- const toolLabel = (part.tool ?? "") || "工具";
443
- await throttledPatch(`\uD83D\uDD27 **正在使用工具:${toolLabel}**`);
444
- }
445
- }
446
- if (event.type === "session.idle" || event.type === "session.error") {
447
- break;
448
- }
449
- }
450
- } catch (err) {
451
- if (!abortController.signal.aborted) {
452
- log("warn", `[${channel.name}] SSE 事件流中断: ${String(err)}`);
453
- }
454
- } finally {
485
+ async function waitForSessionAndReply(client, channel, sessionId, replyTarget, thinkingMsgId, pendingMap) {
486
+ const log = (level, message) => {
487
+ fileLog(level, message);
488
+ client.app.log({ body: { service: "chat-channel", level, message } });
489
+ };
490
+ const SESSION_TIMEOUT_MS = 5 * 60 * 1000;
491
+ await new Promise((resolve, reject) => {
492
+ const timeoutId = setTimeout(() => {
493
+ pendingMap.delete(sessionId);
494
+ log("warn", `[${channel.name}] session 等待超时(5分钟),强制继续读取回复`);
495
+ resolve();
496
+ }, SESSION_TIMEOUT_MS);
497
+ pendingMap.set(sessionId, {
498
+ resolve: () => {
455
499
  clearTimeout(timeoutId);
456
- }
457
- }
458
- } else {
459
- await pollForSessionCompletion(client, channel.name, sessionId);
460
- }
461
- let responseText = null;
462
- try {
463
- const messagesRes = await client.session.messages({ path: { id: sessionId } });
464
- const messages = messagesRes.data ?? [];
465
- const lastAssistant = [...messages].reverse().find((m) => m.info?.role === "assistant");
466
- if (lastAssistant) {
467
- responseText = extractResponseText(lastAssistant.parts ?? []);
468
- }
469
- } catch (err) {
470
- log("error", `[${channel.name}] 获取最终回复失败: ${String(err)}`);
471
- }
472
- if (!responseText) {
473
- responseText = "(AI 没有返回文字回复)";
474
- }
475
- await channel.send(replyTarget, responseText);
476
- }
477
- async function pollForSessionCompletion(client, channelName, sessionId) {
478
- const POLL_INTERVAL_MS = 1000;
479
- const MAX_WAIT_MS = 5 * 60 * 1000;
480
- const started = Date.now();
481
- while (Date.now() - started < MAX_WAIT_MS) {
482
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
483
- try {
484
- const res = await client.session.status();
485
- const allStatuses = res.data ?? {};
486
- const sessionStatus = allStatuses[sessionId];
487
- if (!sessionStatus || sessionStatus.type === "idle")
488
- break;
489
- } catch {
490
- client.app.log({
491
- body: { service: "chat-channel", level: "warn", message: `[${channelName}] 轮询 session 状态失败,继续等待...` }
492
- });
493
- }
494
- }
495
- }
496
- function isSessionEvent(event, sessionId) {
497
- if (!event || !event.type)
498
- return false;
499
- const props = event.properties;
500
- if (!props)
501
- return false;
502
- if (event.type === "message.part.updated") {
503
- return props.part?.sessionID === sessionId;
504
- }
505
- return props.sessionID === sessionId || props.id === sessionId;
500
+ pendingMap.delete(sessionId);
501
+ resolve();
502
+ },
503
+ reject: (err) => {
504
+ clearTimeout(timeoutId);
505
+ pendingMap.delete(sessionId);
506
+ reject(err);
507
+ },
508
+ thinkingMsgId,
509
+ channel,
510
+ replyTarget,
511
+ lastPatchTime: 0,
512
+ pendingPatchTimer: null,
513
+ reasoning: "",
514
+ textParts: new Map,
515
+ textPartOrder: [],
516
+ toolParts: new Map,
517
+ toolPartOrder: []
518
+ });
519
+ }).catch((err) => {
520
+ log("error", `[${channel.name}] session 出错: ${err.message}`);
521
+ });
522
+ log("info", `[${channel.name}] session 完成,卡片已更新,跳过文本回复`);
506
523
  }
507
524
  var ChatChannelPlugin = async ({ client }) => {
508
525
  const configDir = join(process.env["HOME"] ?? `/Users/${process.env["USER"] ?? "unknown"}`, ".config", "opencode");
@@ -535,12 +552,13 @@ var ChatChannelPlugin = async ({ client }) => {
535
552
  });
536
553
  return {};
537
554
  }
555
+ const pendingMap = new Map;
538
556
  const cleanupTimers = [];
539
557
  for (const channel of channels) {
540
558
  const sessionManager = new SessionManager(client, channel.name, (userId) => `${channel.name} 对话 · ${userId}`);
541
559
  const timer = sessionManager.startAutoCleanup();
542
560
  cleanupTimers.push(timer);
543
- const handleMessage = createMessageHandler(channel, sessionManager, client);
561
+ const handleMessage = createMessageHandler(channel, sessionManager, client, pendingMap);
544
562
  await channel.start(handleMessage);
545
563
  }
546
564
  await client.app.log({
@@ -552,15 +570,86 @@ var ChatChannelPlugin = async ({ client }) => {
552
570
  });
553
571
  return {
554
572
  event: async ({ event }) => {
573
+ const props = event.properties;
574
+ if (event.type === "message.part.updated") {
575
+ const part = props?.["part"];
576
+ fileLog("info", `[diag] part.updated raw: ${JSON.stringify(part).slice(0, 300)}`);
577
+ const sessionId = part?.["sessionID"] ?? part?.["session_id"];
578
+ if (sessionId) {
579
+ const entry = pendingMap.get(sessionId);
580
+ if (entry) {
581
+ const partType = part?.["type"];
582
+ const partId = part?.["id"];
583
+ if (partType === "reasoning") {
584
+ entry.reasoning = part?.["text"] ?? "";
585
+ scheduleThrottledPatch(entry);
586
+ } else if (partType === "text") {
587
+ if (partId) {
588
+ if (!entry.textParts.has(partId)) {
589
+ entry.textPartOrder.push(partId);
590
+ }
591
+ entry.textParts.set(partId, part?.["text"] ?? "");
592
+ }
593
+ scheduleThrottledPatch(entry);
594
+ } else if (partType === "tool") {
595
+ const callId = part?.["callID"];
596
+ const toolName = part?.["tool"];
597
+ const toolState = part?.["state"];
598
+ const status = toolState?.["status"];
599
+ const toolInput = toolState?.["input"];
600
+ const toolOutput = toolState?.["output"];
601
+ if (callId && toolName && status) {
602
+ if (!entry.toolParts.has(callId)) {
603
+ entry.toolPartOrder.push(callId);
604
+ }
605
+ entry.toolParts.set(callId, { tool: toolName, status, input: toolInput, output: toolOutput });
606
+ scheduleThrottledPatch(entry);
607
+ }
608
+ }
609
+ }
610
+ }
611
+ }
612
+ if (event.type === "session.idle") {
613
+ const sessionId = props?.["sessionID"] ?? props?.["id"];
614
+ if (sessionId) {
615
+ fileLog("info", `[diag] event hook: session.idle sessionId=${sessionId}`);
616
+ const entry = pendingMap.get(sessionId);
617
+ if (entry) {
618
+ if (entry.pendingPatchTimer) {
619
+ clearTimeout(entry.pendingPatchTimer);
620
+ entry.pendingPatchTimer = null;
621
+ }
622
+ fileLog("info", `[diag] session.idle: thinkingMsgId=${entry.thinkingMsgId}, hasPatchDone=${!!entry.channel.patchDone}, reasoning.len=${entry.reasoning.length}, textParts.count=${entry.textPartOrder.length}, toolParts.count=${entry.toolPartOrder.length}`);
623
+ if (entry.thinkingMsgId && entry.channel.patchDone) {
624
+ const detail = buildDetailContent(entry);
625
+ await entry.channel.patchDone(entry.thinkingMsgId, detail);
626
+ }
627
+ entry.resolve();
628
+ }
629
+ }
630
+ }
555
631
  if (event.type === "session.error") {
632
+ const sessionId = props?.["sessionID"] ?? props?.["id"];
633
+ const errMsg = props?.["message"] ?? "unknown error";
556
634
  await client.app.log({
557
635
  body: {
558
636
  service: "chat-channel",
559
637
  level: "warn",
560
638
  message: "opencode session 出现错误",
561
- extra: event.properties
639
+ extra: props
562
640
  }
563
641
  });
642
+ if (sessionId) {
643
+ fileLog("info", `[diag] event hook: session.error sessionId=${sessionId} err=${errMsg}`);
644
+ const entry = pendingMap.get(sessionId);
645
+ if (entry) {
646
+ if (entry.pendingPatchTimer) {
647
+ clearTimeout(entry.pendingPatchTimer);
648
+ entry.pendingPatchTimer = null;
649
+ }
650
+ entry.reject(new Error(errMsg));
651
+ }
652
+ }
564
653
  }
565
654
  }
566
655
  };
@@ -5,6 +5,11 @@
5
5
  * 与具体渠道无关,所有 channel 实现共享同一套逻辑。
6
6
  */
7
7
  import type { PluginClient } from "./types.js";
8
+ /**
9
+ * 写日志到文件(同步追加),同时调用 client.app.log()。
10
+ * 用于替代纯依赖 client.app.log() 的场景,确保日志落盘可查。
11
+ */
12
+ export declare function fileLog(level: "info" | "warn" | "error", message: string): void;
8
13
  export declare class SessionManager {
9
14
  private readonly client;
10
15
  private readonly channel;
@@ -1 +1 @@
1
- {"version":3,"file":"session-manager.d.ts","sourceRoot":"","sources":["../src/session-manager.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAgB/C,qBAAa,cAAc;IAOvB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAR1B;;;;OAIG;gBAEgB,MAAM,EAAE,YAAY,EACpB,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MACxB;IAGxB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAkC;IAE3D,wCAAwC;IAClC,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IA6BlD,2BAA2B;IAC3B,OAAO,IAAI,IAAI;IASf,oDAAoD;IACpD,gBAAgB,CAAC,UAAU,SAAiB,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC;CAG9E;AAID;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,MAAM,CAO5D;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAiBhD"}
1
+ {"version":3,"file":"session-manager.d.ts","sourceRoot":"","sources":["../src/session-manager.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAO/C;;;GAGG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAQ/E;AAgBD,qBAAa,cAAc;IAOvB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAR1B;;;;OAIG;gBAEgB,MAAM,EAAE,YAAY,EACpB,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MACxB;IAGxB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAkC;IAE3D,wCAAwC;IAClC,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IA6BlD,2BAA2B;IAC3B,OAAO,IAAI,IAAI;IASf,oDAAoD;IACpD,gBAAgB,CAAC,UAAU,SAAiB,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC;CAG9E;AAID;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,MAAM,CAO5D;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAiBhD"}
package/dist/types.d.ts CHANGED
@@ -25,7 +25,9 @@ export interface IncomingMessage {
25
25
  * - start(): 启动监听,收到消息时调用 onMessage 回调
26
26
  * - send(): 向指定 target 发送文本回复
27
27
  * - sendThinkingCard(): 发送占位卡片,返回可更新的 ID(可选)
28
- * - updateThinkingCard(): 更新占位卡片内容(可选)
28
+ * - updateThinkingCard(): 更新占位卡片内容(可选,旧接口向下兼容)
29
+ * - patchProgress(): 更新卡片为进度状态,外部摘要+内部详情折叠面板(可选)
30
+ * - patchDone(): 更新卡片为完成状态(可选)
29
31
  * - stop(): 优雅关闭(可选)
30
32
  */
31
33
  export interface ChatChannel {
@@ -48,11 +50,26 @@ export interface ChatChannel {
48
50
  */
49
51
  sendThinkingCard?(replyTarget: string): Promise<string | null>;
50
52
  /**
51
- * 更新占位消息的内容。
53
+ * 更新占位消息的内容(旧接口,向下兼容)。
52
54
  * @param placeholderId sendThinkingCard 返回的 ID
53
55
  * @param statusText 新状态文本
54
56
  */
55
57
  updateThinkingCard?(placeholderId: string, statusText: string): Promise<void>;
58
+ /**
59
+ * 更新思考卡片为进度状态(节流调用)。
60
+ * 卡片外部显示 summary 摘要,折叠面板内部展示 detail 详细内容。
61
+ * @param placeholderId sendThinkingCard 返回的 ID
62
+ * @param summary 外部摘要文字(如"🤔 正在推理...")
63
+ * @param detail 折叠面板内部展开后的详细内容(reasoning / text 累积)
64
+ * @param stage 当前阶段,用于选择卡片图标和样式
65
+ */
66
+ patchProgress?(placeholderId: string, summary: string, detail: string, stage: "reasoning" | "replying"): Promise<void>;
67
+ /**
68
+ * 更新思考卡片为完成状态。
69
+ * @param placeholderId sendThinkingCard 返回的 ID
70
+ * @param detail 展开后显示的完整内容(reasoning + text 摘要)
71
+ */
72
+ patchDone?(placeholderId: string, detail: string): Promise<void>;
56
73
  /** 优雅停止渠道(可选)。 */
57
74
  stop?(): Promise<void>;
58
75
  }
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAElD,8CAA8C;AAC9C,MAAM,MAAM,YAAY,GAAG,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;AAI3D,kBAAkB;AAClB,MAAM,WAAW,eAAe;IAC9B,oBAAoB;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,qCAAqC;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAID;;;;;;;;;;GAUG;AACH,MAAM,WAAW,WAAW;IAC1B,gCAAgC;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAEtB;;;OAGG;IACH,KAAK,CAAC,SAAS,EAAE,CAAC,GAAG,EAAE,eAAe,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEzE;;;OAGG;IACH,IAAI,CAAC,WAAW,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEvD;;;;OAIG;IACH,gBAAgB,CAAC,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAE/D;;;;OAIG;IACH,kBAAkB,CAAC,CAAC,aAAa,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE9E,kBAAkB;IAClB,IAAI,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAID;;;;;GAKG;AACH,MAAM,MAAM,cAAc,GAAG,CAC3B,MAAM,EAAE,YAAY,KACjB,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;AAIjC;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,OAAO,CAAC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAElD,8CAA8C;AAC9C,MAAM,MAAM,YAAY,GAAG,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;AAI3D,kBAAkB;AAClB,MAAM,WAAW,eAAe;IAC9B,oBAAoB;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,qCAAqC;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAID;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,WAAW;IAC1B,gCAAgC;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAEtB;;;OAGG;IACH,KAAK,CAAC,SAAS,EAAE,CAAC,GAAG,EAAE,eAAe,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEzE;;;OAGG;IACH,IAAI,CAAC,WAAW,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEvD;;;;OAIG;IACH,gBAAgB,CAAC,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAE/D;;;;OAIG;IACH,kBAAkB,CAAC,CAAC,aAAa,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE9E;;;;;;;OAOG;IACH,aAAa,CAAC,CACZ,aAAa,EAAE,MAAM,EACrB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,WAAW,GAAG,UAAU,GAC9B,OAAO,CAAC,IAAI,CAAC,CAAC;IAEjB;;;;OAIG;IACH,SAAS,CAAC,CAAC,aAAa,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEjE,kBAAkB;IAClB,IAAI,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAID;;;;;GAKG;AACH,MAAM,MAAM,cAAc,GAAG,CAC3B,MAAM,EAAE,YAAY,KACjB,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;AAIjC;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,OAAO,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-chat-channel",
3
- "version": "1.2.11",
3
+ "version": "1.2.13",
4
4
  "description": "opencode plugin — multi-channel bot (Feishu/Lark, WeCom) with extensible ChatChannel interface",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",