@wu529778790/open-im 1.2.4-beta.2 → 1.3.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.
@@ -5,8 +5,10 @@ export declare const IMAGE_DIR: string;
5
5
  export declare const READ_ONLY_TOOLS: string[];
6
6
  export declare const TERMINAL_ONLY_COMMANDS: Set<string>;
7
7
  export declare const DEDUP_TTL_MS: number;
8
- /** 飞书流式更新节流:350ms 留出余量(官方 5 QPS 限制),配合块级流式减少 patch 次数 */
9
- export declare const FEISHU_THROTTLE_MS = 350;
8
+ /** 飞书 patch 节流(旧方案,5 QPS */
9
+ export declare const FEISHU_THROTTLE_MS = 200;
10
+ /** CardKit 流式更新节流:80ms(约 12 次/秒,cardElement.content 专为打字机设计,支持更高频率) */
11
+ export declare const CARDKIT_THROTTLE_MS = 80;
10
12
  /** Telegram 编辑消息节流:200ms(open-im 默认值) */
11
13
  export declare const TELEGRAM_THROTTLE_MS = 200;
12
14
  /** WeChat 流式更新节流:1000ms(AGP 协议建议值) */
@@ -14,5 +16,7 @@ export declare const WECHAT_THROTTLE_MS = 1000;
14
16
  export declare const WEWORK_THROTTLE_MS = 500;
15
17
  export declare const MAX_TELEGRAM_MESSAGE_LENGTH = 4000;
16
18
  export declare const MAX_FEISHU_MESSAGE_LENGTH = 4000;
19
+ /** CardKit 流式内容最大长度(卡片上限约 30KB,留余量) */
20
+ export declare const MAX_STREAMING_CONTENT_LENGTH = 25000;
17
21
  export declare const MAX_WECHAT_MESSAGE_LENGTH = 2048;
18
22
  export declare const MAX_WEWORK_MESSAGE_LENGTH = 2048;
package/dist/constants.js CHANGED
@@ -34,8 +34,10 @@ export const TERMINAL_ONLY_COMMANDS = new Set([
34
34
  "/add-dir",
35
35
  ]);
36
36
  export const DEDUP_TTL_MS = 5 * 60 * 1000;
37
- /** 飞书流式更新节流:350ms 留出余量(官方 5 QPS 限制),配合块级流式减少 patch 次数 */
38
- export const FEISHU_THROTTLE_MS = 350;
37
+ /** 飞书 patch 节流(旧方案,5 QPS */
38
+ export const FEISHU_THROTTLE_MS = 200;
39
+ /** CardKit 流式更新节流:80ms(约 12 次/秒,cardElement.content 专为打字机设计,支持更高频率) */
40
+ export const CARDKIT_THROTTLE_MS = 80;
39
41
  /** Telegram 编辑消息节流:200ms(open-im 默认值) */
40
42
  export const TELEGRAM_THROTTLE_MS = 200;
41
43
  /** WeChat 流式更新节流:1000ms(AGP 协议建议值) */
@@ -43,5 +45,7 @@ export const WECHAT_THROTTLE_MS = 1000;
43
45
  export const WEWORK_THROTTLE_MS = 500;
44
46
  export const MAX_TELEGRAM_MESSAGE_LENGTH = 4000;
45
47
  export const MAX_FEISHU_MESSAGE_LENGTH = 4000;
48
+ /** CardKit 流式内容最大长度(卡片上限约 30KB,留余量) */
49
+ export const MAX_STREAMING_CONTENT_LENGTH = 25000;
46
50
  export const MAX_WECHAT_MESSAGE_LENGTH = 2048;
47
51
  export const MAX_WEWORK_MESSAGE_LENGTH = 2048;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * CardKit 2.0 卡片构建 - 支持打字机流式效果
3
+ * 参考 cc-im: https://github.com/congqiu/cc-im
4
+ */
5
+ export type CardStatus = 'processing' | 'thinking' | 'streaming' | 'done' | 'error';
6
+ interface CardOptions {
7
+ content: string;
8
+ status: CardStatus;
9
+ note?: string;
10
+ thinking?: string;
11
+ }
12
+ export declare function truncateForStreaming(text: string): string;
13
+ /** CardKit 2.0 格式,含 element_id 供 cardElement.content 流式更新 */
14
+ export declare function buildCardV2Object(options: CardOptions, cardId?: string): Record<string, unknown>;
15
+ export declare function buildCardV2(options: CardOptions, cardId?: string): string;
16
+ export declare function splitLongContent(text: string, maxLen?: number): string[];
17
+ export {};
@@ -0,0 +1,84 @@
1
+ /**
2
+ * CardKit 2.0 卡片构建 - 支持打字机流式效果
3
+ * 参考 cc-im: https://github.com/congqiu/cc-im
4
+ */
5
+ import { MAX_STREAMING_CONTENT_LENGTH, MAX_FEISHU_MESSAGE_LENGTH } from '../constants.js';
6
+ import { splitLongContent as sharedSplitLongContent, truncateText } from '../shared/utils.js';
7
+ const HEADER_TEMPLATES = {
8
+ processing: 'blue',
9
+ thinking: 'blue',
10
+ streaming: 'blue',
11
+ done: 'green',
12
+ error: 'red',
13
+ };
14
+ const HEADER_TITLES = {
15
+ processing: 'Claude Code - 处理中...',
16
+ thinking: 'Claude Code - 思考中...',
17
+ streaming: 'Claude Code',
18
+ done: 'Claude Code',
19
+ error: 'Claude Code - 错误',
20
+ };
21
+ export function truncateForStreaming(text) {
22
+ return truncateText(text, MAX_STREAMING_CONTENT_LENGTH);
23
+ }
24
+ /** CardKit 2.0 格式,含 element_id 供 cardElement.content 流式更新 */
25
+ export function buildCardV2Object(options, cardId) {
26
+ const { content, status, note, thinking } = options;
27
+ const elements = [];
28
+ // 完成状态下,如果有思考过程,添加折叠面板
29
+ if (status === 'done' && thinking) {
30
+ elements.push({
31
+ tag: 'collapsible_panel',
32
+ expanded: false,
33
+ header: {
34
+ title: { tag: 'markdown', content: '💭 **思考过程**' },
35
+ },
36
+ border: { color: 'grey' },
37
+ elements: [{ tag: 'markdown', content: thinking }],
38
+ });
39
+ }
40
+ elements.push({
41
+ tag: 'markdown',
42
+ content: truncateForStreaming(content) || '...',
43
+ element_id: 'main_content',
44
+ });
45
+ elements.push({
46
+ tag: 'markdown',
47
+ content: note || '',
48
+ text_size: 'notation',
49
+ element_id: 'note_area',
50
+ });
51
+ // 在处理中、思考中和流式输出状态时添加停止按钮
52
+ if ((status === 'processing' || status === 'thinking' || status === 'streaming') &&
53
+ cardId) {
54
+ elements.push({
55
+ tag: 'button',
56
+ text: { tag: 'plain_text', content: '⏹️ 停止' },
57
+ type: 'danger',
58
+ value: { action: 'stop', card_id: cardId },
59
+ element_id: 'action_stop',
60
+ });
61
+ }
62
+ const isActive = status === 'processing' || status === 'thinking' || status === 'streaming';
63
+ return {
64
+ schema: '2.0',
65
+ config: {
66
+ update_multi: true,
67
+ ...(isActive ? { streaming_mode: true } : {}),
68
+ },
69
+ header: {
70
+ template: HEADER_TEMPLATES[status],
71
+ title: { tag: 'plain_text', content: HEADER_TITLES[status] },
72
+ },
73
+ body: {
74
+ direction: 'vertical',
75
+ elements,
76
+ },
77
+ };
78
+ }
79
+ export function buildCardV2(options, cardId) {
80
+ return JSON.stringify(buildCardV2Object(options, cardId));
81
+ }
82
+ export function splitLongContent(text, maxLen = MAX_FEISHU_MESSAGE_LENGTH) {
83
+ return sharedSplitLongContent(text, maxLen);
84
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * CardKit 流式卡片管理 - 打字机效果
3
+ * 参考 cc-im: https://github.com/congqiu/cc-im
4
+ */
5
+ export declare function stopCleanupTimer(): void;
6
+ /** 创建 CardKit 卡片实例 */
7
+ export declare function createCard(cardJson: string): Promise<string>;
8
+ /** 启用流式模式 */
9
+ export declare function enableStreaming(cardId: string): Promise<void>;
10
+ /** 流式更新元素内容(打字机效果) */
11
+ export declare function streamContent(cardId: string, elementId: string, content: string): Promise<void>;
12
+ /** 全量更新卡片(完成/错误状态) */
13
+ export declare function updateCardFull(cardId: string, cardJson: string): Promise<void>;
14
+ /** 通过 card_id 发送卡片消息到聊天 */
15
+ export declare function sendCardMessage(chatId: string, cardId: string): Promise<string>;
16
+ /** 关闭流式模式 */
17
+ export declare function disableStreaming(cardId: string): Promise<void>;
18
+ export declare function markCompleted(cardId: string): void;
19
+ export declare function destroySession(cardId: string): void;
@@ -0,0 +1,243 @@
1
+ /**
2
+ * CardKit 流式卡片管理 - 打字机效果
3
+ * 参考 cc-im: https://github.com/congqiu/cc-im
4
+ */
5
+ import { getClient } from './client.js';
6
+ import { createLogger } from '../logger.js';
7
+ import { withRetry, NonRetryableError } from '../shared/retry.js';
8
+ const log = createLogger('CardKit');
9
+ const MAX_REENABLE_ATTEMPTS = 3;
10
+ const SESSION_TTL_MS = 60 * 60 * 1000;
11
+ const CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
12
+ const sessions = new Map();
13
+ let cleanupTimer = null;
14
+ function ensureCleanupTimer() {
15
+ if (cleanupTimer)
16
+ return;
17
+ cleanupTimer = setInterval(() => {
18
+ const now = Date.now();
19
+ for (const [id, s] of sessions) {
20
+ if (now - s.createdAt > SESSION_TTL_MS) {
21
+ sessions.delete(id);
22
+ log.info(`Auto-cleaned expired card session: ${id}`);
23
+ }
24
+ }
25
+ }, CLEANUP_INTERVAL_MS);
26
+ cleanupTimer.unref?.();
27
+ }
28
+ export function stopCleanupTimer() {
29
+ if (cleanupTimer) {
30
+ clearInterval(cleanupTimer);
31
+ cleanupTimer = null;
32
+ }
33
+ }
34
+ function nextSeq(cardId) {
35
+ const s = sessions.get(cardId);
36
+ if (!s)
37
+ return -1;
38
+ s.sequence += 1;
39
+ return s.sequence;
40
+ }
41
+ /** 创建 CardKit 卡片实例 */
42
+ export async function createCard(cardJson) {
43
+ ensureCleanupTimer();
44
+ const client = getClient();
45
+ const res = await client.cardkit.v1.card.create({
46
+ data: { type: 'card_json', data: cardJson },
47
+ });
48
+ const cardId = res.data?.card_id;
49
+ if (!cardId) {
50
+ log.error('card.create response:', JSON.stringify(res, null, 2));
51
+ throw new Error(`card.create returned no card_id (code=${res.code}, msg=${res.msg})`);
52
+ }
53
+ sessions.set(cardId, {
54
+ cardId,
55
+ sequence: 0,
56
+ streamingEnabled: false,
57
+ completed: false,
58
+ createdAt: Date.now(),
59
+ reenableFailCount: 0,
60
+ });
61
+ log.debug(`Card created: ${cardId}`);
62
+ return cardId;
63
+ }
64
+ /** 启用流式模式 */
65
+ export async function enableStreaming(cardId) {
66
+ await withRetry(async () => {
67
+ const s = sessions.get(cardId);
68
+ if (s?.completed)
69
+ return;
70
+ const client = getClient();
71
+ const res = await client.cardkit.v1.card.settings({
72
+ path: { card_id: cardId },
73
+ data: {
74
+ settings: JSON.stringify({ streaming_mode: true }),
75
+ sequence: nextSeq(cardId),
76
+ },
77
+ });
78
+ if (res?.code && res.code !== 0) {
79
+ if (res.code === 200400) {
80
+ log.warn(`enableStreaming rate limited: ${res.msg}`);
81
+ throw new NonRetryableError(`enableStreaming rate limited: code=${res.code}, msg=${res.msg}`);
82
+ }
83
+ log.error(`enableStreaming failed: code=${res.code}, msg=${res.msg}`);
84
+ throw new Error(`enableStreaming error: code=${res.code}, msg=${res.msg}`);
85
+ }
86
+ if (s)
87
+ s.streamingEnabled = true;
88
+ log.debug(`Streaming enabled for card ${cardId}`);
89
+ });
90
+ }
91
+ /** 流式更新元素内容(打字机效果) */
92
+ export async function streamContent(cardId, elementId, content) {
93
+ const client = getClient();
94
+ const call = async (s) => {
95
+ return await client.cardkit.v1.cardElement.content({
96
+ path: { card_id: cardId, element_id: elementId },
97
+ data: { content, sequence: s },
98
+ });
99
+ };
100
+ const seq = nextSeq(cardId);
101
+ if (seq === -1)
102
+ return;
103
+ let res;
104
+ try {
105
+ res = await call(seq);
106
+ }
107
+ catch (err) {
108
+ const respData = err?.response?.data;
109
+ if (respData?.code === 99991400)
110
+ return;
111
+ log.warn(`streamContent exception: ${err?.message ?? err}`);
112
+ return;
113
+ }
114
+ const code = res?.code;
115
+ if (!code || code === 0) {
116
+ const s = sessions.get(cardId);
117
+ if (s)
118
+ s.reenableFailCount = 0;
119
+ return;
120
+ }
121
+ if (code === 200810)
122
+ return;
123
+ if (code === 300317)
124
+ return;
125
+ if (code === 200400)
126
+ return;
127
+ if (code === 200937)
128
+ return;
129
+ if (code === 200740)
130
+ return;
131
+ if (code === 200850 || code === 300309) {
132
+ const s = sessions.get(cardId);
133
+ if (!s || s.completed)
134
+ return;
135
+ if (s.reenableFailCount >= MAX_REENABLE_ATTEMPTS)
136
+ return;
137
+ log.warn(`Streaming closed/timeout (${code}) for card ${cardId}, re-enabling...`);
138
+ try {
139
+ await enableStreaming(cardId);
140
+ const s2 = sessions.get(cardId);
141
+ if (!s2 || s2.completed)
142
+ return;
143
+ const retryRes = await call(nextSeq(cardId));
144
+ if (retryRes?.code && retryRes.code !== 0) {
145
+ s.reenableFailCount++;
146
+ log.warn(`Retry still failed: code=${retryRes.code}, skipping (${s.reenableFailCount}/${MAX_REENABLE_ATTEMPTS})`);
147
+ }
148
+ else {
149
+ s.reenableFailCount = 0;
150
+ }
151
+ }
152
+ catch {
153
+ s.reenableFailCount++;
154
+ log.warn(`Re-enable failed for card ${cardId}, skipping (${s.reenableFailCount}/${MAX_REENABLE_ATTEMPTS})`);
155
+ }
156
+ return;
157
+ }
158
+ log.error(`streamContent failed: code=${code}, msg=${res.msg}`);
159
+ }
160
+ /** 全量更新卡片(完成/错误状态) */
161
+ export async function updateCardFull(cardId, cardJson) {
162
+ await withRetry(async () => {
163
+ const client = getClient();
164
+ const res = await client.cardkit.v1.card.update({
165
+ path: { card_id: cardId },
166
+ data: {
167
+ card: { type: 'card_json', data: cardJson },
168
+ sequence: nextSeq(cardId),
169
+ },
170
+ });
171
+ const code = res?.code;
172
+ if (code && code !== 0) {
173
+ if (code === 200810 || code === 300317)
174
+ return;
175
+ log.error(`updateCardFull failed: code=${code}, msg=${res.msg}`);
176
+ throw new Error(`updateCardFull error: code=${code}, msg=${res.msg}`);
177
+ }
178
+ log.debug(`Card ${cardId} fully updated`);
179
+ });
180
+ }
181
+ /** 通过 card_id 发送卡片消息到聊天 */
182
+ export async function sendCardMessage(chatId, cardId) {
183
+ const client = getClient();
184
+ const content = JSON.stringify({ type: 'card', data: { card_id: cardId } });
185
+ const res = await (client.im.v1?.message ?? client.im.message).create({
186
+ params: { receive_id_type: 'chat_id' },
187
+ data: {
188
+ receive_id: chatId,
189
+ content,
190
+ msg_type: 'interactive',
191
+ },
192
+ });
193
+ const messageId = res.data?.message_id ?? '';
194
+ log.debug(`Card message sent: messageId=${messageId}, cardId=${cardId}`);
195
+ return messageId;
196
+ }
197
+ /** 关闭流式模式 */
198
+ export async function disableStreaming(cardId) {
199
+ const s = sessions.get(cardId);
200
+ if (!s || !s.streamingEnabled)
201
+ return;
202
+ s.completed = true;
203
+ try {
204
+ await withRetry(async () => {
205
+ const client = getClient();
206
+ const seq = nextSeq(cardId);
207
+ if (seq === -1)
208
+ return;
209
+ const res = await client.cardkit.v1.card.settings({
210
+ path: { card_id: cardId },
211
+ data: {
212
+ settings: JSON.stringify({ streaming_mode: false }),
213
+ sequence: seq,
214
+ },
215
+ });
216
+ if (res?.code && res.code !== 0) {
217
+ if (res.code === 200400) {
218
+ throw new Error(`disableStreaming rate limited: code=${res.code}, msg=${res.msg}`);
219
+ }
220
+ log.warn(`disableStreaming failed: code=${res.code}, msg=${res.msg}`);
221
+ }
222
+ else {
223
+ s.streamingEnabled = false;
224
+ log.debug(`Streaming disabled for card ${cardId}`);
225
+ }
226
+ }, { maxRetries: 3, baseDelayMs: 500 });
227
+ }
228
+ catch (err) {
229
+ log.warn(`disableStreaming error for card ${cardId}:`, err);
230
+ }
231
+ finally {
232
+ s.streamingEnabled = false;
233
+ }
234
+ }
235
+ export function markCompleted(cardId) {
236
+ const s = sessions.get(cardId);
237
+ if (s)
238
+ s.completed = true;
239
+ }
240
+ export function destroySession(cardId) {
241
+ sessions.delete(cardId);
242
+ log.debug(`Session destroyed for card ${cardId}`);
243
+ }
@@ -2,15 +2,17 @@ import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { AccessControl } from '../access/access-control.js';
4
4
  import { RequestQueue } from '../queue/request-queue.js';
5
- import { sendThinkingMessage, updateMessage, sendFinalMessages, sendTextReply, sendTextReplyByOpenId, startTypingLoop, sendImageReply, createFeishuButtonCard, sendModeCard, createFeishuModeCardReadOnly, delayUpdateCard, } from './message-sender.js';
5
+ import { sendTextReply, sendTextReplyByOpenId, startTypingLoop, sendImageReply, createFeishuButtonCard, sendModeCard, createFeishuModeCardReadOnly, delayUpdateCard, sendThinkingCard, streamContentUpdate, sendFinalCards, sendErrorCard, } from './message-sender.js';
6
6
  import { registerPermissionSender, resolvePermissionById } from '../hook/permission-server.js';
7
7
  import { setPermissionMode } from '../permission-mode/session-mode.js';
8
8
  import { MODE_LABELS } from '../permission-mode/types.js';
9
9
  import { CommandHandler } from '../commands/handler.js';
10
10
  import { getAdapter } from '../adapters/registry.js';
11
11
  import { runAITask } from '../shared/ai-task.js';
12
+ import { buildCardV2 } from './card-builder.js';
13
+ import { disableStreaming, updateCardFull, destroySession } from './cardkit-manager.js';
12
14
  import { startTaskCleanup } from '../shared/task-cleanup.js';
13
- import { FEISHU_THROTTLE_MS, IMAGE_DIR } from '../constants.js';
15
+ import { CARDKIT_THROTTLE_MS, IMAGE_DIR } from '../constants.js';
14
16
  import { setActiveChatId } from '../shared/active-chats.js';
15
17
  import { setChatUser } from '../shared/chat-user-map.js';
16
18
  import { createLogger } from '../logger.js';
@@ -127,34 +129,30 @@ export function setupFeishuHandlers(config, sessionManager) {
127
129
  const sessionId = convId ? sessionManager.getSessionIdForConv(userId, convId) : undefined;
128
130
  log.info(`[handleAIRequest] Running ${config.aiCommand} for user ${userId}, sessionId=${sessionId ?? 'new'}`);
129
131
  const toolId = config.aiCommand;
130
- let msgId;
132
+ // 使用 CardKit 打字机效果(80ms 节流,约 12 次/秒,比 patch 5 QPS 更流畅)
133
+ let cardHandle;
131
134
  try {
132
- msgId = await sendThinkingMessage(chatId, replyToMessageId, toolId);
135
+ cardHandle = await sendThinkingCard(chatId, toolId);
133
136
  }
134
137
  catch (err) {
135
- log.error('Failed to send thinking message:', err);
138
+ log.error('Failed to send thinking card:', err);
136
139
  return;
137
140
  }
141
+ const { messageId: msgId, cardId } = cardHandle;
138
142
  const stopTyping = startTypingLoop(chatId);
139
- const taskKey = `${userId}:${msgId}`;
143
+ const taskKey = `${userId}:${cardId}`;
144
+ const streamUpdate = (content, toolNote) => {
145
+ const note = toolNote ? '输出中...\n' + toolNote : '输出中...';
146
+ streamContentUpdate(cardId, content, note).catch((e) => log.debug('Stream update failed (will retry on next update):', e?.message ?? e));
147
+ };
140
148
  await runAITask({ config, sessionManager }, { userId, chatId, workDir, sessionId, convId, platform: 'feishu', taskKey }, prompt, toolAdapter, {
141
- throttleMs: FEISHU_THROTTLE_MS,
142
- minContentDeltaChars: 80, // 块级流式:内容增长≥80 字符才 patch,减少 API 调用
143
- streamUpdate: async (content, toolNote) => {
144
- const note = toolNote ? '输出中...\n' + toolNote : '输出中...';
145
- try {
146
- await updateMessage(chatId, msgId, content, 'streaming', note, toolId);
147
- }
148
- catch (err) {
149
- log.debug('Stream update failed (will retry on next update):', err);
150
- }
151
- },
152
- sendComplete: async (content, note) => {
153
- // Use sendFinalMessages to handle the final result
154
- await sendFinalMessages(chatId, msgId, content, note ?? '', toolId);
149
+ throttleMs: CARDKIT_THROTTLE_MS,
150
+ streamUpdate,
151
+ sendComplete: async (content, note, thinkingText) => {
152
+ await sendFinalCards(chatId, msgId, cardId, content, note ?? '', thinkingText);
155
153
  },
156
154
  sendError: async (error) => {
157
- await updateMessage(chatId, msgId, `错误:${error}`, 'error', '执行失败', toolId);
155
+ await sendErrorCard(cardId, error);
158
156
  },
159
157
  extraCleanup: () => {
160
158
  stopTyping();
@@ -163,6 +161,10 @@ export function setupFeishuHandlers(config, sessionManager) {
163
161
  onTaskReady: (state) => {
164
162
  runningTasks.set(taskKey, state);
165
163
  },
164
+ onThinkingToText: (content) => {
165
+ const resetCard = buildCardV2({ content: content || '...', status: 'streaming' }, cardId);
166
+ updateCardFull(cardId, resetCard).catch((e) => log.warn('Thinking→text transition update failed:', e?.message ?? e));
167
+ },
166
168
  sendImage: (path) => sendImageReply(chatId, path),
167
169
  });
168
170
  }
@@ -255,6 +257,40 @@ export function setupFeishuHandlers(config, sessionManager) {
255
257
  const chatId = event?.context?.open_chat_id ?? event?.context?.chat_id ?? event?.context?.open_id ?? '';
256
258
  const userId = event?.sender?.sender_id?.open_id ?? event?.operator?.open_id ?? '';
257
259
  log.info(`[handleCardAction] chatId=${chatId}, userId=${userId}, actionValue=${JSON.stringify(actionValue)}`);
260
+ let actionData = null;
261
+ try {
262
+ let parsed = actionValue;
263
+ if (typeof parsed === 'string') {
264
+ parsed = JSON.parse(parsed);
265
+ if (typeof parsed === 'string')
266
+ parsed = JSON.parse(parsed);
267
+ }
268
+ actionData = parsed;
269
+ }
270
+ catch {
271
+ /* ignore */
272
+ }
273
+ if (actionData?.action === 'stop' && actionData.card_id) {
274
+ const cardId = actionData.card_id;
275
+ const taskKey = `${userId}:${cardId}`;
276
+ const taskInfo = runningTasks.get(taskKey);
277
+ if (taskInfo) {
278
+ log.info(`User ${userId} stopped task for card ${cardId}`);
279
+ const stoppedContent = taskInfo.latestContent || '(任务已停止,暂无输出)';
280
+ runningTasks.delete(taskKey);
281
+ taskInfo.settle();
282
+ taskInfo.handle.abort();
283
+ const stoppedCard = buildCardV2({ content: stoppedContent, status: 'done', note: '⏹️ 已停止' });
284
+ disableStreaming(cardId)
285
+ .then(() => updateCardFull(cardId, stoppedCard))
286
+ .catch((e) => log.warn('Stop card update failed:', e?.message ?? e))
287
+ .finally(() => destroySession(cardId));
288
+ }
289
+ else {
290
+ log.warn(`No running task found for key: ${taskKey}`);
291
+ }
292
+ return { toast: { type: 'success', content: '已停止' } };
293
+ }
258
294
  // 处理 mode 按钮(兼容 value 为对象或 JSON 字符串)
259
295
  const modeAv = parseActionValue(actionValue);
260
296
  if (modeAv?.action === 'mode' && modeAv.value) {
@@ -1,3 +1,7 @@
1
+ export interface CardHandle {
2
+ messageId: string;
3
+ cardId: string;
4
+ }
1
5
  export type MessageStatus = 'thinking' | 'streaming' | 'done' | 'error';
2
6
  /**
3
7
  * Create Feishu card with action buttons
@@ -20,6 +24,14 @@ export declare function createFeishuModeCardReadOnly(currentMode: string): Recor
20
24
  export declare function delayUpdateCard(token: string, card: Record<string, unknown>, openIds?: string[]): Promise<void>;
21
25
  export declare function sendModeCard(chatId: string, _userId: string, currentMode: string): Promise<void>;
22
26
  export declare function sendThinkingMessage(chatId: string, replyToMessageId: string | undefined, toolId?: string): Promise<string>;
27
+ /** CardKit 打字机:发送思考卡片并返回 cardId + messageId */
28
+ export declare function sendThinkingCard(chatId: string, toolId?: string): Promise<CardHandle>;
29
+ /** CardKit 流式更新(打字机效果) */
30
+ export declare function streamContentUpdate(cardId: string, content: string, note?: string): Promise<void>;
31
+ /** CardKit 完成:关闭流式、全量更新、溢出分片 */
32
+ export declare function sendFinalCards(chatId: string, _messageId: string, cardId: string, fullContent: string, note: string, thinking?: string): Promise<void>;
33
+ /** CardKit 错误卡片 */
34
+ export declare function sendErrorCard(cardId: string, error: string): Promise<void>;
23
35
  export declare function updateMessage(chatId: string, messageId: string, content: string, status: MessageStatus, note?: string, toolId?: string): Promise<void>;
24
36
  export declare function sendFinalMessages(chatId: string, messageId: string, fullContent: string, note: string, toolId?: string): Promise<void>;
25
37
  export declare function sendTextReply(chatId: string, text: string): Promise<void>;
@@ -3,6 +3,8 @@ import { readFileSync } from 'node:fs';
3
3
  import { createLogger } from '../logger.js';
4
4
  import { splitLongContent } from '../shared/utils.js';
5
5
  import { MAX_FEISHU_MESSAGE_LENGTH } from '../constants.js';
6
+ import { buildCardV2, splitLongContent as cardSplitLongContent, truncateForStreaming } from './card-builder.js';
7
+ import { createCard, enableStreaming, sendCardMessage, streamContent as cardkitStreamContent, updateCardFull, markCompleted, disableStreaming, destroySession, } from './cardkit-manager.js';
6
8
  const log = createLogger('FeishuSender');
7
9
  const STATUS_CONFIG = {
8
10
  thinking: { icon: '🔵', template: 'blue', title: '思考中' },
@@ -253,6 +255,62 @@ export async function sendThinkingMessage(chatId, replyToMessageId, toolId = 'cl
253
255
  throw err;
254
256
  }
255
257
  }
258
+ /** CardKit 打字机:发送思考卡片并返回 cardId + messageId */
259
+ export async function sendThinkingCard(chatId, toolId = 'claude') {
260
+ const initialCard = buildCardV2({ content: '正在启动...', status: 'processing', note: '请稍候' });
261
+ const cardId = await createCard(initialCard);
262
+ const [, messageId] = await Promise.all([
263
+ enableStreaming(cardId),
264
+ sendCardMessage(chatId, cardId),
265
+ ]);
266
+ const cardWithButton = buildCardV2({ content: '等待 Claude 响应...', status: 'processing', note: '请稍候' }, cardId);
267
+ await updateCardFull(cardId, cardWithButton);
268
+ log.debug(`Processing card created: cardId=${cardId}, messageId=${messageId}`);
269
+ return { messageId, cardId };
270
+ }
271
+ /** CardKit 流式更新(打字机效果) */
272
+ export async function streamContentUpdate(cardId, content, note) {
273
+ const truncated = truncateForStreaming(content) || '...';
274
+ const updates = [cardkitStreamContent(cardId, 'main_content', truncated)];
275
+ if (note)
276
+ updates.push(cardkitStreamContent(cardId, 'note_area', note));
277
+ await Promise.all(updates);
278
+ }
279
+ /** CardKit 完成:关闭流式、全量更新、溢出分片 */
280
+ export async function sendFinalCards(chatId, _messageId, cardId, fullContent, note, thinking) {
281
+ const parts = cardSplitLongContent(fullContent);
282
+ markCompleted(cardId);
283
+ await disableStreaming(cardId);
284
+ const finalCard = buildCardV2({ content: parts[0], status: 'done', note, thinking }, cardId);
285
+ await updateCardFull(cardId, finalCard);
286
+ const client = getClient();
287
+ for (let i = 1; i < parts.length; i++) {
288
+ const overflowContent = `${parts[i]}\n\n_*(续 ${i + 1}/${parts.length})*_`;
289
+ const overflowCard = createFeishuCard(getToolTitle('claude', 'done'), overflowContent, 'done', note);
290
+ await client.im.message.create({
291
+ data: {
292
+ receive_id: chatId,
293
+ msg_type: 'interactive',
294
+ content: overflowCard,
295
+ },
296
+ params: { receive_id_type: 'chat_id' },
297
+ });
298
+ }
299
+ destroySession(cardId);
300
+ }
301
+ /** CardKit 错误卡片 */
302
+ export async function sendErrorCard(cardId, error) {
303
+ markCompleted(cardId);
304
+ await disableStreaming(cardId);
305
+ try {
306
+ const errorCard = buildCardV2({ content: `错误:${error}`, status: 'error', note: '执行失败' });
307
+ await updateCardFull(cardId, errorCard);
308
+ }
309
+ catch (err) {
310
+ log.error('Failed to send error card:', err);
311
+ }
312
+ destroySession(cardId);
313
+ }
256
314
  // Track if patch API is working for this session
257
315
  let patchApiWorking = true;
258
316
  let patchFailCount = 0;
@@ -0,0 +1,10 @@
1
+ /** 抛出此错误时 withRetry 不再重试,直接穿透 */
2
+ export declare class NonRetryableError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ export interface RetryOptions {
6
+ maxRetries?: number;
7
+ baseDelayMs?: number;
8
+ maxDelayMs?: number;
9
+ }
10
+ export declare function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): Promise<T>;
@@ -0,0 +1,26 @@
1
+ import { createLogger } from '../logger.js';
2
+ const log = createLogger('Retry');
3
+ /** 抛出此错误时 withRetry 不再重试,直接穿透 */
4
+ export class NonRetryableError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = 'NonRetryableError';
8
+ }
9
+ }
10
+ export async function withRetry(fn, opts) {
11
+ const maxRetries = opts?.maxRetries ?? 3;
12
+ const baseDelay = opts?.baseDelayMs ?? 500;
13
+ const maxDelay = opts?.maxDelayMs ?? 5000;
14
+ for (let attempt = 0;; attempt++) {
15
+ try {
16
+ return await fn();
17
+ }
18
+ catch (err) {
19
+ if (err instanceof NonRetryableError || attempt >= maxRetries)
20
+ throw err;
21
+ const delay = Math.min(baseDelay * 2 ** attempt + Math.random() * 200, maxDelay);
22
+ log.warn(`Retry ${attempt + 1}/${maxRetries} after ${Math.round(delay)}ms: ${err?.message ?? err}`);
23
+ await new Promise((r) => setTimeout(r, delay));
24
+ }
25
+ }
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.2.4-beta.2",
3
+ "version": "1.3.0",
4
4
  "description": "Multi-platform IM bridge for AI CLI tools (Claude, Codex, Cursor)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",