@axhub/genie 0.1.6 → 0.1.8

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.
Files changed (37) hide show
  1. package/dist/api-docs.html +351 -909
  2. package/dist/assets/index-CVjMty4a.js +902 -0
  3. package/dist/assets/index-eo5scY_Z.css +32 -0
  4. package/dist/index.html +5 -5
  5. package/dist/manifest.json +2 -2
  6. package/package.json +8 -2
  7. package/server/channels/core/ChannelManager.js +399 -0
  8. package/server/channels/core/PluginManager.js +59 -0
  9. package/server/channels/index.js +3 -0
  10. package/server/channels/plugins/BasePlugin.js +46 -0
  11. package/server/channels/plugins/dingtalk/DingTalkAdapter.js +156 -0
  12. package/server/channels/plugins/dingtalk/DingTalkPlugin.js +592 -0
  13. package/server/channels/plugins/dingtalk/index.js +2 -0
  14. package/server/channels/plugins/lark/LarkAdapter.js +100 -0
  15. package/server/channels/plugins/lark/LarkCards.js +43 -0
  16. package/server/channels/plugins/lark/LarkPlugin.js +260 -0
  17. package/server/channels/runtime/AgentRuntimeAdapter.js +179 -0
  18. package/server/channels/runtime/DingTalkStreamWriter.js +105 -0
  19. package/server/channels/runtime/LarkStreamWriter.js +99 -0
  20. package/server/channels/store/ChannelStore.js +236 -0
  21. package/server/database/db.js +109 -1
  22. package/server/database/init.sql +47 -1
  23. package/server/gemini-cli.js +280 -0
  24. package/server/index.js +230 -11
  25. package/server/openai-codex.js +104 -8
  26. package/server/opencode-cli.js +673 -0
  27. package/server/projects.js +645 -5
  28. package/server/routes/agent.js +40 -12
  29. package/server/routes/channels.js +221 -0
  30. package/server/routes/cli-auth.js +317 -0
  31. package/server/routes/commands.js +29 -3
  32. package/server/routes/git.js +15 -5
  33. package/server/routes/opencode.js +72 -0
  34. package/shared/modelConstants.js +62 -17
  35. package/dist/assets/index-CtRxrKDm.css +0 -32
  36. package/dist/assets/index-OENtErNy.js +0 -1249
  37. package/server/database/auth.db +0 -0
@@ -0,0 +1,43 @@
1
+ import { truncateLarkText } from './LarkAdapter.js';
2
+
3
+ export function createTextCard(text, title = null, template = 'blue') {
4
+ const normalized = truncateLarkText(text || '');
5
+
6
+ const card = {
7
+ config: {
8
+ wide_screen_mode: true,
9
+ },
10
+ elements: [
11
+ {
12
+ tag: 'markdown',
13
+ content: normalized || ' ',
14
+ },
15
+ ],
16
+ };
17
+
18
+ if (title) {
19
+ card.header = {
20
+ title: {
21
+ tag: 'plain_text',
22
+ content: title,
23
+ },
24
+ template,
25
+ };
26
+ }
27
+
28
+ return card;
29
+ }
30
+
31
+ export function createUnknownUserIdCard() {
32
+ return createTextCard('无法识别你的飞书用户ID,请联系管理员检查机器人事件权限。', '访问受限', 'orange');
33
+ }
34
+
35
+ export function createUnauthorizedUserCard(userId) {
36
+ const text = [
37
+ `你的飞书用户ID: ${userId}`,
38
+ '',
39
+ '请将此 ID 添加到 AxHub 设置 > Channels > Lark 的允许用户列表。',
40
+ ].join('\n');
41
+
42
+ return createTextCard(text, '访问受限', 'orange');
43
+ }
@@ -0,0 +1,260 @@
1
+ import * as lark from '@larksuiteoapi/node-sdk';
2
+
3
+ import { BasePlugin } from '../BasePlugin.js';
4
+ import { toIncomingMessage, truncateLarkText } from './LarkAdapter.js';
5
+ import { createTextCard } from './LarkCards.js';
6
+
7
+ const EVENT_CACHE_TTL = 5 * 60 * 1000;
8
+ const EVENT_CACHE_CLEANUP_INTERVAL = 60 * 1000;
9
+
10
+ export class LarkPlugin extends BasePlugin {
11
+ constructor() {
12
+ super('lark');
13
+ this.client = null;
14
+ this.wsClient = null;
15
+ this.eventDispatcher = null;
16
+ this.processedEvents = new Map();
17
+ this.cleanupTimer = null;
18
+ }
19
+
20
+ async onInitialize(config) {
21
+ const appId = config?.appId;
22
+ const appSecret = config?.appSecret;
23
+
24
+ if (!appId || !appSecret) {
25
+ throw new Error('Lark App ID and App Secret are required');
26
+ }
27
+
28
+ this.client = new lark.Client({
29
+ appId,
30
+ appSecret,
31
+ appType: lark.AppType.SelfBuild,
32
+ domain: lark.Domain.Feishu,
33
+ });
34
+ }
35
+
36
+ async onStart() {
37
+ if (!this.client) {
38
+ throw new Error('Lark client is not initialized');
39
+ }
40
+
41
+ const appId = this.config?.appId;
42
+ const appSecret = this.config?.appSecret;
43
+ if (!appId || !appSecret) {
44
+ throw new Error('Lark credentials are missing');
45
+ }
46
+
47
+ this.eventDispatcher = new lark.EventDispatcher({
48
+ encryptKey: this.config?.encryptKey || '',
49
+ verificationToken: this.config?.verificationToken || '',
50
+ });
51
+
52
+ this.eventDispatcher.register({
53
+ 'im.message.receive_v1': async (payload) => {
54
+ await this.handleMessageEvent(payload);
55
+ },
56
+ });
57
+
58
+ this.wsClient = new lark.WSClient({
59
+ appId,
60
+ appSecret,
61
+ domain: lark.Domain.Feishu,
62
+ loggerLevel: lark.LoggerLevel.info,
63
+ });
64
+
65
+ this.wsClient.start({ eventDispatcher: this.eventDispatcher }).catch((error) => {
66
+ console.error('[LarkPlugin] WebSocket error:', error);
67
+ });
68
+
69
+ this.startCleanupTimer();
70
+ }
71
+
72
+ async onStop() {
73
+ this.stopCleanupTimer();
74
+ if (this.wsClient?.stop && typeof this.wsClient.stop === 'function') {
75
+ try {
76
+ await this.wsClient.stop();
77
+ } catch (error) {
78
+ console.warn('[LarkPlugin] Failed to stop WebSocket client cleanly:', error?.message || error);
79
+ }
80
+ }
81
+ this.wsClient = null;
82
+ this.eventDispatcher = null;
83
+ this.client = null;
84
+ this.processedEvents.clear();
85
+ }
86
+
87
+ async sendText(chatId, text) {
88
+ if (!this.client) {
89
+ throw new Error('Lark client not initialized');
90
+ }
91
+
92
+ const receiveIdType = this.getReceiveIdType(chatId);
93
+ const content = {
94
+ text: truncateLarkText(text || ''),
95
+ };
96
+
97
+ const response = await this.client.im.message.create({
98
+ params: {
99
+ receive_id_type: receiveIdType,
100
+ },
101
+ data: {
102
+ receive_id: chatId,
103
+ msg_type: 'text',
104
+ content: JSON.stringify(content),
105
+ },
106
+ });
107
+
108
+ return response.data?.message_id || '';
109
+ }
110
+
111
+ async sendCard(chatId, card) {
112
+ if (!this.client) {
113
+ throw new Error('Lark client not initialized');
114
+ }
115
+
116
+ const receiveIdType = this.getReceiveIdType(chatId);
117
+ const response = await this.client.im.message.create({
118
+ params: {
119
+ receive_id_type: receiveIdType,
120
+ },
121
+ data: {
122
+ receive_id: chatId,
123
+ msg_type: 'interactive',
124
+ content: JSON.stringify(card),
125
+ },
126
+ });
127
+
128
+ return response.data?.message_id || '';
129
+ }
130
+
131
+ async editCard(_chatId, messageId, card) {
132
+ if (!this.client) {
133
+ throw new Error('Lark client not initialized');
134
+ }
135
+
136
+ try {
137
+ await this.client.im.message.patch({
138
+ path: {
139
+ message_id: messageId,
140
+ },
141
+ data: {
142
+ content: JSON.stringify(card),
143
+ },
144
+ });
145
+ } catch (error) {
146
+ const errorCode = error?.response?.data?.code || error?.code;
147
+ const errorMessage = error?.response?.data?.msg || error?.message || '';
148
+
149
+ if (errorCode === 230002 || String(errorMessage).includes('not modified')) {
150
+ return;
151
+ }
152
+
153
+ if (String(errorMessage).includes('NOT a card')) {
154
+ return;
155
+ }
156
+
157
+ throw error;
158
+ }
159
+ }
160
+
161
+ buildStreamingCard(text) {
162
+ return createTextCard(text);
163
+ }
164
+
165
+ getReceiveIdType(receiveId) {
166
+ if (receiveId.startsWith('ou_')) return 'open_id';
167
+ if (receiveId.startsWith('oc_')) return 'chat_id';
168
+ if (receiveId.startsWith('on_')) return 'union_id';
169
+ return 'user_id';
170
+ }
171
+
172
+ async handleMessageEvent(payload) {
173
+ const eventData = payload?.event || payload;
174
+ if (!eventData?.message) {
175
+ return;
176
+ }
177
+
178
+ const eventId = eventData.message?.message_id;
179
+ if (eventId && this.isProcessed(eventId)) {
180
+ return;
181
+ }
182
+ if (eventId) {
183
+ this.markProcessed(eventId);
184
+ }
185
+
186
+ const incoming = toIncomingMessage(eventData);
187
+ if (!incoming || !this.messageHandler) {
188
+ return;
189
+ }
190
+
191
+ try {
192
+ await this.messageHandler(incoming);
193
+ } catch (error) {
194
+ console.error('[LarkPlugin] Failed to process incoming message:', error);
195
+ }
196
+ }
197
+
198
+ isProcessed(eventId) {
199
+ return this.processedEvents.has(eventId);
200
+ }
201
+
202
+ markProcessed(eventId) {
203
+ this.processedEvents.set(eventId, Date.now());
204
+ }
205
+
206
+ startCleanupTimer() {
207
+ if (this.cleanupTimer) {
208
+ return;
209
+ }
210
+
211
+ this.cleanupTimer = setInterval(() => {
212
+ const now = Date.now();
213
+ for (const [eventId, timestamp] of this.processedEvents.entries()) {
214
+ if (now - timestamp > EVENT_CACHE_TTL) {
215
+ this.processedEvents.delete(eventId);
216
+ }
217
+ }
218
+ }, EVENT_CACHE_CLEANUP_INTERVAL);
219
+ }
220
+
221
+ stopCleanupTimer() {
222
+ if (!this.cleanupTimer) {
223
+ return;
224
+ }
225
+
226
+ clearInterval(this.cleanupTimer);
227
+ this.cleanupTimer = null;
228
+ }
229
+
230
+ static async testConnection(appId, appSecret) {
231
+ if (!appId || !appSecret) {
232
+ return { success: false, error: 'App ID and App Secret are required' };
233
+ }
234
+
235
+ try {
236
+ const client = new lark.Client({
237
+ appId,
238
+ appSecret,
239
+ appType: lark.AppType.SelfBuild,
240
+ domain: lark.Domain.Feishu,
241
+ });
242
+
243
+ await client.auth.tenantAccessToken.internal({
244
+ data: {
245
+ app_id: appId,
246
+ app_secret: appSecret,
247
+ },
248
+ });
249
+
250
+ return {
251
+ success: true,
252
+ };
253
+ } catch (error) {
254
+ return {
255
+ success: false,
256
+ error: error?.message || 'Failed to connect to Lark',
257
+ };
258
+ }
259
+ }
260
+ }
@@ -0,0 +1,179 @@
1
+ import { queryClaudeSDK } from '../../claude-sdk.js';
2
+ import { spawnCursor } from '../../cursor-cli.js';
3
+ import { queryCodex } from '../../openai-codex.js';
4
+ import { queryGemini } from '../../gemini-cli.js';
5
+ import { queryOpencode } from '../../opencode-cli.js';
6
+ import { CODEX_MODELS, GEMINI_MODELS, OPENCODE_MODELS } from '../../../shared/modelConstants.js';
7
+
8
+ function normalizeBackend(backend) {
9
+ const normalized = String(backend || '').trim().toLowerCase();
10
+ if (['claude', 'cursor', 'codex', 'gemini', 'opencode'].includes(normalized)) {
11
+ return normalized;
12
+ }
13
+ return 'codex';
14
+ }
15
+
16
+ function getDefaultModel(backend) {
17
+ if (backend === 'codex') return CODEX_MODELS.DEFAULT;
18
+ if (backend === 'gemini') return GEMINI_MODELS.DEFAULT;
19
+ if (backend === 'opencode') return OPENCODE_MODELS.DEFAULT;
20
+ return undefined;
21
+ }
22
+
23
+ function createWriter(callbacks) {
24
+ const state = {
25
+ sessionId: null,
26
+ done: false,
27
+ failed: false,
28
+ codexDeltaItems: new Set(),
29
+ };
30
+
31
+ const ensureSessionId = (sessionId) => {
32
+ if (!sessionId || state.sessionId === sessionId) {
33
+ return;
34
+ }
35
+
36
+ state.sessionId = sessionId;
37
+ callbacks.onSessionId?.(sessionId);
38
+ };
39
+
40
+ return {
41
+ isSSEStreamWriter: true,
42
+
43
+ setSessionId(sessionId) {
44
+ ensureSessionId(sessionId);
45
+ },
46
+
47
+ send(payload) {
48
+ if (!payload || typeof payload !== 'object') {
49
+ return;
50
+ }
51
+
52
+ ensureSessionId(payload.sessionId);
53
+
54
+ if (payload.type === 'session-created') {
55
+ ensureSessionId(payload.sessionId);
56
+ return;
57
+ }
58
+
59
+ if (payload.type === 'claude-response') {
60
+ const delta = payload?.data?.delta?.text;
61
+ if (payload?.data?.type === 'content_block_delta' && typeof delta === 'string' && delta.length > 0) {
62
+ callbacks.onDelta?.(delta);
63
+ return;
64
+ }
65
+
66
+ if (payload?.data?.type === 'content_block_stop') {
67
+ state.done = true;
68
+ }
69
+
70
+ return;
71
+ }
72
+
73
+ if (payload.type === 'codex-response') {
74
+ if (payload?.data?.type === 'item_delta' && typeof payload?.data?.delta === 'string' && payload.data.delta.length > 0) {
75
+ const itemId = payload?.data?.itemId ? String(payload.data.itemId) : null;
76
+ const isReasoning = !!payload?.data?.isReasoning || payload?.data?.itemType === 'reasoning';
77
+
78
+ // Feishu channel does not output model reasoning/thinking content.
79
+ if (isReasoning) {
80
+ return;
81
+ }
82
+
83
+ if (itemId) {
84
+ state.codexDeltaItems.add(itemId);
85
+ }
86
+
87
+ callbacks.onDelta?.(payload.data.delta);
88
+ return;
89
+ }
90
+
91
+ if (payload?.data?.type === 'item_done' && typeof payload?.data?.content === 'string' && payload.data.content.length > 0) {
92
+ const itemId = payload?.data?.itemId ? String(payload.data.itemId) : null;
93
+ const isReasoning = !!payload?.data?.isReasoning || payload?.data?.itemType === 'reasoning';
94
+ const hasDeltaStreamed = !!(itemId && state.codexDeltaItems.has(itemId));
95
+
96
+ // Feishu channel does not output model reasoning/thinking content.
97
+ if (isReasoning) {
98
+ if (itemId) {
99
+ state.codexDeltaItems.delete(itemId);
100
+ }
101
+ return;
102
+ }
103
+
104
+ if (!hasDeltaStreamed) {
105
+ callbacks.onDelta?.(payload.data.content);
106
+ }
107
+
108
+ if (itemId) {
109
+ state.codexDeltaItems.delete(itemId);
110
+ }
111
+ }
112
+
113
+ return;
114
+ }
115
+
116
+ if (payload.type === 'claude-complete' || payload.type === 'codex-complete') {
117
+ state.done = true;
118
+ return;
119
+ }
120
+
121
+ if (payload.type === 'claude-error' || payload.type === 'codex-error' || payload.type === 'cursor-error') {
122
+ state.failed = true;
123
+ callbacks.onError?.(new Error(payload.error || 'Agent runtime error'));
124
+ }
125
+ },
126
+
127
+ getState() {
128
+ return state;
129
+ },
130
+ };
131
+ }
132
+
133
+ export async function runBackendSession({
134
+ backend,
135
+ message,
136
+ projectPath,
137
+ sessionId,
138
+ model,
139
+ onSessionId,
140
+ onDelta,
141
+ onError,
142
+ }) {
143
+ const resolvedBackend = normalizeBackend(backend);
144
+ const resolvedModel = model || getDefaultModel(resolvedBackend);
145
+
146
+ const writer = createWriter({
147
+ onSessionId,
148
+ onDelta,
149
+ onError,
150
+ });
151
+
152
+ const options = {
153
+ projectPath,
154
+ cwd: projectPath,
155
+ sessionId: sessionId || undefined,
156
+ model: resolvedModel,
157
+ permissionMode: 'bypassPermissions',
158
+ resume: !!sessionId,
159
+ skipPermissions: true,
160
+ };
161
+
162
+ if (resolvedBackend === 'claude') {
163
+ await queryClaudeSDK(message, options, writer);
164
+ } else if (resolvedBackend === 'cursor') {
165
+ await spawnCursor(message, options, writer);
166
+ } else if (resolvedBackend === 'codex') {
167
+ await queryCodex(message, options, writer);
168
+ } else if (resolvedBackend === 'gemini') {
169
+ await queryGemini(message, options, writer);
170
+ } else if (resolvedBackend === 'opencode') {
171
+ await queryOpencode(message, options, writer);
172
+ } else {
173
+ throw new Error(`Unsupported backend: ${resolvedBackend}`);
174
+ }
175
+
176
+ return {
177
+ sessionId: writer.getState().sessionId || sessionId || null,
178
+ };
179
+ }
@@ -0,0 +1,105 @@
1
+ import { convertHtmlToDingTalkMarkdown, truncateDingTalkText } from '../plugins/dingtalk/DingTalkAdapter.js';
2
+
3
+ const UPDATE_THROTTLE_MS = 500;
4
+
5
+ export class DingTalkStreamWriter {
6
+ constructor(plugin, chatId) {
7
+ this.plugin = plugin;
8
+ this.chatId = chatId;
9
+ this.messageId = null;
10
+ this.buffer = '';
11
+ this.lastContent = null;
12
+ this.lastUpdateAt = 0;
13
+ this.pendingTimer = null;
14
+ }
15
+
16
+ async start(initialText = '⏳ Thinking...') {
17
+ this.buffer = initialText;
18
+ this.messageId = await this.plugin.sendMessage(this.chatId, {
19
+ type: 'text',
20
+ text: this.formatText(this.buffer),
21
+ });
22
+ this.lastContent = this.formatText(this.buffer);
23
+ this.lastUpdateAt = Date.now();
24
+ return this.messageId;
25
+ }
26
+
27
+ append(delta) {
28
+ if (!delta) {
29
+ return;
30
+ }
31
+
32
+ this.buffer += delta;
33
+ this.scheduleFlush();
34
+ }
35
+
36
+ async finish() {
37
+ await this.flushNow(true);
38
+ this.clearTimer();
39
+ }
40
+
41
+ async fail(errorMessage) {
42
+ this.buffer = `❌ ${errorMessage || '处理失败,请稍后重试。'}`;
43
+ await this.flushNow(true);
44
+ this.clearTimer();
45
+ }
46
+
47
+ async flushNow(isFinal = false) {
48
+ if (!this.messageId) {
49
+ return;
50
+ }
51
+
52
+ this.clearTimer();
53
+ await this.applyEdit(isFinal);
54
+ }
55
+
56
+ scheduleFlush() {
57
+ const elapsed = Date.now() - this.lastUpdateAt;
58
+ if (elapsed >= UPDATE_THROTTLE_MS) {
59
+ void this.applyEdit(false).catch((error) => {
60
+ console.warn('[DingTalkStreamWriter] Failed to update streaming card:', error?.message || error);
61
+ });
62
+ return;
63
+ }
64
+
65
+ this.clearTimer();
66
+ this.pendingTimer = setTimeout(() => {
67
+ void this.applyEdit(false).catch((error) => {
68
+ console.warn('[DingTalkStreamWriter] Failed to update streaming card:', error?.message || error);
69
+ });
70
+ }, UPDATE_THROTTLE_MS - elapsed);
71
+ }
72
+
73
+ clearTimer() {
74
+ if (!this.pendingTimer) {
75
+ return;
76
+ }
77
+
78
+ clearTimeout(this.pendingTimer);
79
+ this.pendingTimer = null;
80
+ }
81
+
82
+ formatText(text) {
83
+ return truncateDingTalkText(convertHtmlToDingTalkMarkdown(text || ''), 3900);
84
+ }
85
+
86
+ async applyEdit(isFinal = false) {
87
+ if (!this.messageId) {
88
+ return;
89
+ }
90
+
91
+ const normalized = this.formatText(this.buffer) || ' ';
92
+ if (!isFinal && this.lastContent === normalized) {
93
+ return;
94
+ }
95
+
96
+ await this.plugin.editMessage(this.chatId, this.messageId, {
97
+ type: 'text',
98
+ text: normalized,
99
+ isFinal,
100
+ });
101
+
102
+ this.lastContent = normalized;
103
+ this.lastUpdateAt = Date.now();
104
+ }
105
+ }
@@ -0,0 +1,99 @@
1
+ import { convertHtmlToLarkMarkdown, truncateLarkText } from '../plugins/lark/LarkAdapter.js';
2
+
3
+ const UPDATE_THROTTLE_MS = 500;
4
+
5
+ export class LarkStreamWriter {
6
+ constructor(plugin, chatId) {
7
+ this.plugin = plugin;
8
+ this.chatId = chatId;
9
+ this.messageId = null;
10
+ this.buffer = '';
11
+ this.lastContent = null;
12
+ this.lastUpdateAt = 0;
13
+ this.pendingTimer = null;
14
+ }
15
+
16
+ async start(initialText = '⏳ Thinking...') {
17
+ this.buffer = initialText;
18
+ const card = this.plugin.buildStreamingCard(this.formatText(this.buffer));
19
+ this.messageId = await this.plugin.sendCard(this.chatId, card);
20
+ this.lastContent = this.buffer;
21
+ this.lastUpdateAt = Date.now();
22
+ return this.messageId;
23
+ }
24
+
25
+ append(delta) {
26
+ if (!delta) {
27
+ return;
28
+ }
29
+
30
+ this.buffer += delta;
31
+ this.scheduleFlush();
32
+ }
33
+
34
+ async finish() {
35
+ await this.flushNow();
36
+ this.clearTimer();
37
+ }
38
+
39
+ async fail(errorMessage) {
40
+ this.buffer = `❌ ${errorMessage || '处理失败,请稍后重试。'}`;
41
+ await this.flushNow();
42
+ this.clearTimer();
43
+ }
44
+
45
+ async flushNow() {
46
+ if (!this.messageId) {
47
+ return;
48
+ }
49
+
50
+ this.clearTimer();
51
+ await this.applyEdit();
52
+ }
53
+
54
+ scheduleFlush() {
55
+ const elapsed = Date.now() - this.lastUpdateAt;
56
+ if (elapsed >= UPDATE_THROTTLE_MS) {
57
+ void this.applyEdit().catch((error) => {
58
+ console.warn('[LarkStreamWriter] Failed to update streaming card:', error?.message || error);
59
+ });
60
+ return;
61
+ }
62
+
63
+ this.clearTimer();
64
+ this.pendingTimer = setTimeout(() => {
65
+ void this.applyEdit().catch((error) => {
66
+ console.warn('[LarkStreamWriter] Failed to update streaming card:', error?.message || error);
67
+ });
68
+ }, UPDATE_THROTTLE_MS - elapsed);
69
+ }
70
+
71
+ clearTimer() {
72
+ if (!this.pendingTimer) {
73
+ return;
74
+ }
75
+
76
+ clearTimeout(this.pendingTimer);
77
+ this.pendingTimer = null;
78
+ }
79
+
80
+ formatText(text) {
81
+ return truncateLarkText(convertHtmlToLarkMarkdown(text || ''), 3900);
82
+ }
83
+
84
+ async applyEdit() {
85
+ if (!this.messageId) {
86
+ return;
87
+ }
88
+
89
+ const normalized = this.formatText(this.buffer);
90
+ if (this.lastContent === normalized) {
91
+ return;
92
+ }
93
+
94
+ const card = this.plugin.buildStreamingCard(normalized || ' ');
95
+ await this.plugin.editCard(this.chatId, this.messageId, card);
96
+ this.lastContent = normalized;
97
+ this.lastUpdateAt = Date.now();
98
+ }
99
+ }