bingocode 1.0.40 → 1.1.42

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 (63) hide show
  1. package/bin/bingo-win.cjs +2 -1
  2. package/bin/bingocode-win.cjs +2 -1
  3. package/bin/claude-win.cjs +2 -1
  4. package/bun.lock +1716 -0
  5. package/package.json +14 -2
  6. package/src/server/config/providers.yaml +1 -1
  7. package/src/server/proxy/transform/anthropicToOpenaiChat.ts +23 -9
  8. package/adapters/README.md +0 -87
  9. package/adapters/common/__tests__/chat-queue.test.ts +0 -61
  10. package/adapters/common/__tests__/format.test.ts +0 -148
  11. package/adapters/common/__tests__/http-client.test.ts +0 -105
  12. package/adapters/common/__tests__/message-buffer.test.ts +0 -84
  13. package/adapters/common/__tests__/message-dedup.test.ts +0 -57
  14. package/adapters/common/__tests__/session-store.test.ts +0 -62
  15. package/adapters/common/__tests__/ws-bridge.test.ts +0 -177
  16. package/adapters/common/attachment/__tests__/attachment-limits.test.ts +0 -52
  17. package/adapters/common/attachment/__tests__/attachment-store.test.ts +0 -108
  18. package/adapters/common/attachment/__tests__/image-block-watcher.test.ts +0 -115
  19. package/adapters/common/attachment/attachment-limits.ts +0 -58
  20. package/adapters/common/attachment/attachment-store.ts +0 -121
  21. package/adapters/common/attachment/attachment-types.ts +0 -29
  22. package/adapters/common/attachment/image-block-watcher.ts +0 -94
  23. package/adapters/common/chat-queue.ts +0 -24
  24. package/adapters/common/config.ts +0 -96
  25. package/adapters/common/format.ts +0 -229
  26. package/adapters/common/http-client.ts +0 -107
  27. package/adapters/common/message-buffer.ts +0 -91
  28. package/adapters/common/message-dedup.ts +0 -57
  29. package/adapters/common/pairing.ts +0 -149
  30. package/adapters/common/session-store.ts +0 -60
  31. package/adapters/common/ws-bridge.ts +0 -282
  32. package/adapters/feishu/__tests__/card-errors.test.ts +0 -194
  33. package/adapters/feishu/__tests__/cardkit.test.ts +0 -295
  34. package/adapters/feishu/__tests__/extract-payload.test.ts +0 -77
  35. package/adapters/feishu/__tests__/feishu.test.ts +0 -907
  36. package/adapters/feishu/__tests__/flush-controller.test.ts +0 -290
  37. package/adapters/feishu/__tests__/markdown-style.test.ts +0 -353
  38. package/adapters/feishu/__tests__/media.test.ts +0 -120
  39. package/adapters/feishu/__tests__/streaming-card.test.ts +0 -914
  40. package/adapters/feishu/card-errors.ts +0 -151
  41. package/adapters/feishu/cardkit.ts +0 -294
  42. package/adapters/feishu/extract-payload.ts +0 -95
  43. package/adapters/feishu/flush-controller.ts +0 -149
  44. package/adapters/feishu/index.ts +0 -1275
  45. package/adapters/feishu/markdown-style.ts +0 -212
  46. package/adapters/feishu/media.ts +0 -176
  47. package/adapters/feishu/streaming-card.ts +0 -612
  48. package/adapters/package.json +0 -23
  49. package/adapters/telegram/__tests__/media.test.ts +0 -86
  50. package/adapters/telegram/__tests__/telegram.test.ts +0 -115
  51. package/adapters/telegram/index.ts +0 -754
  52. package/adapters/telegram/media.ts +0 -89
  53. package/adapters/tsconfig.json +0 -18
  54. package/runtime/mac_helper.py +0 -775
  55. package/runtime/requirements-win.txt +0 -7
  56. package/runtime/requirements.txt +0 -6
  57. package/runtime/test_helpers.py +0 -322
  58. package/runtime/win_helper.py +0 -723
  59. package/scripts/count-app-loc.ts +0 -256
  60. package/scripts/release.ts +0 -130
  61. package/start-cli.bat +0 -7
  62. package/stubs/ant-claude-for-chrome-mcp.ts +0 -24
  63. package/stubs/color-diff-napi.ts +0 -45
@@ -1,151 +0,0 @@
1
- /**
2
- * Feishu CardKit API 错误码解析与谓词
3
- *
4
- * 参考实现: openclaw-lark/src/card/card-error.ts + src/core/api-error.ts
5
- *
6
- * Lark SDK 抛出的错误对象结构有多种:
7
- * - SDK 把 Feishu 的 {code, msg} 直接挂在 error 对象上
8
- * - Axios 风格: error.response.data.{code, msg}
9
- * - data.code 嵌套(某些包装层)
10
- *
11
- * 此模块把这些统一成 { code, subCode, errMsg } 结构,
12
- * 供 streaming-card-controller 判断是否跳帧重试、或降级到 Patch 路径。
13
- */
14
-
15
- // ---------------------------------------------------------------------------
16
- // Error code constants
17
- // ---------------------------------------------------------------------------
18
-
19
- /** 卡片 API 级别错误码。 */
20
- export const CARD_ERROR = {
21
- /** 发送频率限制。需跳过当前帧,下次 flush 继续。 */
22
- RATE_LIMITED: 230020,
23
- /** 卡片内容创建失败(通用码,需看子错误确认具体原因)。 */
24
- CARD_CONTENT_FAILED: 230099,
25
- } as const
26
-
27
- /**
28
- * 230099 的子错误码,嵌套在 msg 的 `ErrCode: xxx` 字段中。
29
- * 11310 是通用的"元素超限"码,需配合 errMsg 匹配具体原因。
30
- */
31
- export const CARD_CONTENT_SUB_ERROR = {
32
- /** 卡片元素(表格等)数量超限 */
33
- ELEMENT_LIMIT: 11310,
34
- } as const
35
-
36
- // ---------------------------------------------------------------------------
37
- // Code extraction
38
- // ---------------------------------------------------------------------------
39
-
40
- function coerceCode(value: unknown): number | undefined {
41
- if (typeof value === 'number' && Number.isFinite(value)) return value
42
- if (typeof value === 'string') {
43
- const parsed = Number(value)
44
- if (Number.isFinite(parsed)) return parsed
45
- }
46
- return undefined
47
- }
48
-
49
- /**
50
- * 从 Lark SDK 抛错对象中提取飞书 API code。支持三种结构:
51
- * - `{ code }` (SDK 直接挂载)
52
- * - `{ data: { code } }` (响应体嵌套)
53
- * - `{ response: { data: { code } } }` (Axios 风格)
54
- */
55
- export function extractLarkApiCode(err: unknown): number | undefined {
56
- if (!err || typeof err !== 'object') return undefined
57
- const e = err as {
58
- code?: unknown
59
- data?: { code?: unknown }
60
- response?: { data?: { code?: unknown } }
61
- }
62
- return coerceCode(e.code) ?? coerceCode(e.data?.code) ?? coerceCode(e.response?.data?.code)
63
- }
64
-
65
- // ---------------------------------------------------------------------------
66
- // Sub-error extraction
67
- // ---------------------------------------------------------------------------
68
-
69
- /**
70
- * 从 msg 字符串里提取子错误码(`ErrCode: xxx`)。
71
- *
72
- * 示例输入:
73
- * "Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ..."
74
- * 返回: 11310
75
- */
76
- export function extractSubCode(msg: string): number | null {
77
- const match = /ErrCode:\s*(\d+)/.exec(msg)
78
- if (!match) return null
79
- const code = Number(match[1])
80
- return Number.isFinite(code) ? code : null
81
- }
82
-
83
- // ---------------------------------------------------------------------------
84
- // Structured error parsing
85
- // ---------------------------------------------------------------------------
86
-
87
- export type CardApiErrorInfo = {
88
- code: number
89
- subCode: number | null
90
- errMsg: string
91
- }
92
-
93
- /**
94
- * 从任意抛错对象中解析卡片 API 错误结构。
95
- *
96
- * 返回 { code, subCode, errMsg }。无法提取 code 时返回 null。
97
- */
98
- export function parseCardApiError(err: unknown): CardApiErrorInfo | null {
99
- const code = extractLarkApiCode(err)
100
- if (code === undefined) return null
101
-
102
- // 按优先级提取 msg 文本
103
- let errMsg = ''
104
- if (err && typeof err === 'object') {
105
- const e = err as {
106
- msg?: unknown
107
- message?: unknown
108
- response?: { data?: { msg?: unknown } }
109
- }
110
- if (typeof e.msg === 'string') {
111
- errMsg = e.msg
112
- } else if (typeof e.response?.data?.msg === 'string') {
113
- errMsg = e.response.data.msg
114
- } else if (typeof e.message === 'string') {
115
- errMsg = e.message
116
- }
117
- }
118
-
119
- const subCode = extractSubCode(errMsg)
120
- return { code, subCode, errMsg }
121
- }
122
-
123
- // ---------------------------------------------------------------------------
124
- // Helper predicates
125
- // ---------------------------------------------------------------------------
126
-
127
- /** 判断错误是否为卡片发送频率限制(230020)。 */
128
- export function isCardRateLimitError(err: unknown): boolean {
129
- const parsed = parseCardApiError(err)
130
- if (!parsed) return false
131
- return parsed.code === CARD_ERROR.RATE_LIMITED
132
- }
133
-
134
- /**
135
- * 判断错误是否为卡片表格数超限。
136
- *
137
- * 匹配条件: code 230099 + subCode 11310 + errMsg 含 "table number over limit"
138
- * (11310 是通用元素超限码,光靠它不够;必须同时检查 errMsg 锁定是表格数量问题)。
139
- *
140
- * 实际生产错误格式(openclaw-lark 2026-03 实测):
141
- * "Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; "
142
- */
143
- export function isCardTableLimitError(err: unknown): boolean {
144
- const parsed = parseCardApiError(err)
145
- if (!parsed) return false
146
- return (
147
- parsed.code === CARD_ERROR.CARD_CONTENT_FAILED &&
148
- parsed.subCode === CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT &&
149
- /table number over limit/i.test(parsed.errMsg)
150
- )
151
- }
@@ -1,294 +0,0 @@
1
- /**
2
- * 飞书 CardKit API 薄封装
3
- *
4
- * 这是生产路径的核心:openclaw-lark 的 CardKit 主路径等价实现。
5
- *
6
- * 五步流程:
7
- * 1. createCardEntity() —— 创建卡片实体,返回 card_id
8
- * 2. sendCardAsMessage() —— 通过 IM 消息把卡片挂到聊天窗,返回 message_id
9
- * 3. streamCardContent() —— 循环调用,按 element_id 增量追加文本
10
- * 4. setCardStreamingMode() —— 关闭流式模式(收尾前必须做)
11
- * 5. updateCardKitCard() —— 全量替换卡片为最终态
12
- *
13
- * 关键约束:
14
- * - 每次 3/4/5 类调用必须携带**单调递增**的 sequence,否则飞书拒绝
15
- * - streamCardContent 传的是**完整累计文本**,不是 delta
16
- * - 必须关闭 streaming_mode 后卡片才能被用户交互
17
- *
18
- * 参考实现: openclaw-lark/src/card/cardkit.ts
19
- */
20
-
21
- import type * as Lark from '@larksuiteoapi/node-sdk'
22
-
23
- // ---------------------------------------------------------------------------
24
- // Constants
25
- // ---------------------------------------------------------------------------
26
-
27
- /** 流式 markdown 元素的固定 element_id。卡片 JSON 里用这个 id 标记要被
28
- * `cardElement.content()` 更新的那一个 markdown 元素。 */
29
- export const STREAMING_ELEMENT_ID = 'streaming_content'
30
-
31
- // ---------------------------------------------------------------------------
32
- // Types
33
- // ---------------------------------------------------------------------------
34
-
35
- /**
36
- * SDK 返回的通用响应结构。
37
- * SDK 的 TypeScript 类型不完整,运行时实际返回 { code, msg, data }。
38
- * 我们统一当成 CardKitResponse 处理以免到处 `as any`。
39
- */
40
- type CardKitResponse = {
41
- code?: number
42
- msg?: string
43
- data?: Record<string, unknown>
44
- [key: string]: unknown
45
- }
46
-
47
- /** 非零 code 时抛出的结构化错误。字段与 Lark SDK 的标准错误对齐,
48
- * 可被 card-errors.ts 的 parseCardApiError 识别。 */
49
- export class CardKitApiError extends Error {
50
- readonly code: number
51
- readonly msg: string
52
-
53
- constructor(params: { api: string; code: number; msg: string; context: string }) {
54
- const { api, code, msg, context } = params
55
- super(`cardkit ${api} FAILED: code=${code}, msg=${msg}, ${context}`)
56
- this.name = 'CardKitApiError'
57
- this.code = code
58
- this.msg = msg
59
- }
60
- }
61
-
62
- type LarkClient = Lark.Client
63
-
64
- // ---------------------------------------------------------------------------
65
- // Response check
66
- // ---------------------------------------------------------------------------
67
-
68
- /**
69
- * 检查 CardKit 响应的 body-level code。非 0 → 抛 CardKitApiError。
70
- *
71
- * Fail-fast 策略: 让 streaming-card 用 try/catch 配合 card-errors 统一
72
- * 判断是速率限制还是真错误。
73
- */
74
- function assertCardKitOk(params: {
75
- resp: CardKitResponse
76
- api: string
77
- context: string
78
- }): void {
79
- const { resp, api, context } = params
80
- const code = resp.code
81
- if (code !== undefined && code !== 0) {
82
- throw new CardKitApiError({
83
- api,
84
- code,
85
- msg: typeof resp.msg === 'string' ? resp.msg : '',
86
- context,
87
- })
88
- }
89
- }
90
-
91
- // ---------------------------------------------------------------------------
92
- // Step 1 — createCardEntity
93
- // ---------------------------------------------------------------------------
94
-
95
- /**
96
- * 创建一张 CardKit 卡片实体,返回 card_id。
97
- *
98
- * 此时卡片还没挂到任何聊天窗。需要再调 sendCardAsMessage 才能显示。
99
- *
100
- * @param client Lark SDK client
101
- * @param card Schema 2.0 格式的卡片 JSON
102
- * @returns 飞书分配的 card_id(失败时抛错)
103
- */
104
- export async function createCardEntity(
105
- client: LarkClient,
106
- card: Record<string, unknown>,
107
- ): Promise<string> {
108
- // SDK 返回类型不完整,cast 到运行时实际结构
109
- const resp = (await client.cardkit.v1.card.create({
110
- data: {
111
- type: 'card_json',
112
- data: JSON.stringify(card),
113
- },
114
- })) as unknown as CardKitResponse
115
-
116
- assertCardKitOk({
117
- resp,
118
- api: 'card.create',
119
- context: `cardLen=${JSON.stringify(card).length}`,
120
- })
121
-
122
- // 兼容不同 SDK 包装层:data.card_id 优先,回退顶层 card_id
123
- const cardId =
124
- (resp.data?.card_id as string | undefined) ??
125
- (resp.card_id as string | undefined)
126
-
127
- if (!cardId) {
128
- throw new CardKitApiError({
129
- api: 'card.create',
130
- code: resp.code ?? -1,
131
- msg: 'response missing card_id',
132
- context: `resp=${JSON.stringify(resp).slice(0, 200)}`,
133
- })
134
- }
135
- return cardId
136
- }
137
-
138
- // ---------------------------------------------------------------------------
139
- // Step 2 — sendCardAsMessage
140
- // ---------------------------------------------------------------------------
141
-
142
- /**
143
- * 把 CardKit 卡片通过 IM 消息挂到聊天窗。
144
- *
145
- * content 格式: `{"type":"card","data":{"card_id":"xxx"}}`
146
- * msg_type 固定为 `interactive`。
147
- *
148
- * @param client Lark SDK client
149
- * @param chatId 目标 chat_id
150
- * @param cardId CardKit card_id(由 createCardEntity 产生)
151
- * @param replyToMessageId 可选。如果提供,走 im.message.reply;否则 im.message.create
152
- * @returns 飞书分配的 message_id
153
- */
154
- export async function sendCardAsMessage(
155
- client: LarkClient,
156
- chatId: string,
157
- cardId: string,
158
- replyToMessageId?: string,
159
- ): Promise<string> {
160
- const content = JSON.stringify({
161
- type: 'card',
162
- data: { card_id: cardId },
163
- })
164
-
165
- if (replyToMessageId) {
166
- const resp = await client.im.message.reply({
167
- path: { message_id: replyToMessageId },
168
- data: { content, msg_type: 'interactive' },
169
- })
170
- const messageId = resp.data?.message_id
171
- if (!messageId) {
172
- throw new CardKitApiError({
173
- api: 'im.message.reply',
174
- code: -1,
175
- msg: 'response missing message_id',
176
- context: `cardId=${cardId}`,
177
- })
178
- }
179
- return messageId
180
- }
181
-
182
- const resp = await client.im.message.create({
183
- params: { receive_id_type: 'chat_id' },
184
- data: {
185
- receive_id: chatId,
186
- msg_type: 'interactive',
187
- content,
188
- },
189
- })
190
- const messageId = resp.data?.message_id
191
- if (!messageId) {
192
- throw new CardKitApiError({
193
- api: 'im.message.create',
194
- code: -1,
195
- msg: 'response missing message_id',
196
- context: `chatId=${chatId} cardId=${cardId}`,
197
- })
198
- }
199
- return messageId
200
- }
201
-
202
- // ---------------------------------------------------------------------------
203
- // Step 3 — streamCardContent
204
- // ---------------------------------------------------------------------------
205
-
206
- /**
207
- * 流式更新指定 element 的内容。飞书自动对比旧内容做 diff,在客户端
208
- * 渲染打字机效果。
209
- *
210
- * **重要**: `content` 必须传**完整累计文本**,不是 delta。
211
- * sequence 必须**单调递增**,否则飞书拒绝。
212
- *
213
- * @param client Lark SDK client
214
- * @param cardId CardKit card_id
215
- * @param elementId 要更新的元素 id(通常是 STREAMING_ELEMENT_ID)
216
- * @param content 完整累计文本
217
- * @param sequence 单调递增序列号
218
- */
219
- export async function streamCardContent(
220
- client: LarkClient,
221
- cardId: string,
222
- elementId: string,
223
- content: string,
224
- sequence: number,
225
- ): Promise<void> {
226
- const resp = (await client.cardkit.v1.cardElement.content({
227
- data: { content, sequence },
228
- path: { card_id: cardId, element_id: elementId },
229
- })) as unknown as CardKitResponse
230
-
231
- assertCardKitOk({
232
- resp,
233
- api: 'cardElement.content',
234
- context: `seq=${sequence} len=${content.length}`,
235
- })
236
- }
237
-
238
- // ---------------------------------------------------------------------------
239
- // Step 4 — setCardStreamingMode
240
- // ---------------------------------------------------------------------------
241
-
242
- /**
243
- * 开/关卡片的流式模式。收尾前必须调用 `streamingMode: false`,
244
- * 否则卡片会保持"只读"状态,用户点按钮没反应。
245
- */
246
- export async function setCardStreamingMode(
247
- client: LarkClient,
248
- cardId: string,
249
- streamingMode: boolean,
250
- sequence: number,
251
- ): Promise<void> {
252
- const resp = (await client.cardkit.v1.card.settings({
253
- data: {
254
- settings: JSON.stringify({ streaming_mode: streamingMode }),
255
- sequence,
256
- },
257
- path: { card_id: cardId },
258
- })) as unknown as CardKitResponse
259
-
260
- assertCardKitOk({
261
- resp,
262
- api: 'card.settings',
263
- context: `seq=${sequence} streaming_mode=${streamingMode}`,
264
- })
265
- }
266
-
267
- // ---------------------------------------------------------------------------
268
- // Step 5 — updateCardKitCard
269
- // ---------------------------------------------------------------------------
270
-
271
- /**
272
- * 全量替换卡片为新的 JSON。用于流式结束后把卡片切换成最终态
273
- * (加 header template、footer、完成样式等)。
274
- */
275
- export async function updateCardKitCard(
276
- client: LarkClient,
277
- cardId: string,
278
- card: Record<string, unknown>,
279
- sequence: number,
280
- ): Promise<void> {
281
- const resp = (await client.cardkit.v1.card.update({
282
- data: {
283
- card: { type: 'card_json', data: JSON.stringify(card) },
284
- sequence,
285
- },
286
- path: { card_id: cardId },
287
- })) as unknown as CardKitResponse
288
-
289
- assertCardKitOk({
290
- resp,
291
- api: 'card.update',
292
- context: `seq=${sequence} cardId=${cardId}`,
293
- })
294
- }
@@ -1,95 +0,0 @@
1
- /**
2
- * Feishu inbound message parser.
3
- *
4
- * Converts a raw Feishu `im.message.receive_v1` event payload (the JSON
5
- * string inside `message.content` plus its `message_type`) into a
6
- * structured `InboundPayload` containing:
7
- * - plain text (for direct forwarding to Claude)
8
- * - a list of `PendingDownload` refs describing any attachments we
9
- * need to fetch via FeishuMediaService.downloadResource()
10
- *
11
- * Supports the five message_type values we care about:
12
- * - text → text only
13
- * - post → rich text (text nodes + img + file elements)
14
- * - image → single image_key
15
- * - file → single file_key
16
- * - file_archive → single file_key (same shape as file)
17
- *
18
- * Any other shape returns an empty payload (text: '', downloads: []).
19
- */
20
-
21
- export type PendingDownload =
22
- | { kind: 'image'; fileKey: string; fileName?: string }
23
- | { kind: 'file'; fileKey: string; fileName?: string }
24
-
25
- export interface InboundPayload {
26
- text: string
27
- pendingDownloads: PendingDownload[]
28
- }
29
-
30
- export function extractInboundPayload(content: string, msgType: string): InboundPayload {
31
- let parsed: any
32
- try {
33
- parsed = JSON.parse(content)
34
- } catch {
35
- return { text: '', pendingDownloads: [] }
36
- }
37
-
38
- if (msgType === 'text') {
39
- return {
40
- text: typeof parsed.text === 'string' ? parsed.text : '',
41
- pendingDownloads: [],
42
- }
43
- }
44
-
45
- if (msgType === 'image') {
46
- if (typeof parsed.image_key === 'string' && parsed.image_key) {
47
- return {
48
- text: '',
49
- pendingDownloads: [{ kind: 'image', fileKey: parsed.image_key }],
50
- }
51
- }
52
- return { text: '', pendingDownloads: [] }
53
- }
54
-
55
- if (msgType === 'file' || msgType === 'file_archive') {
56
- if (typeof parsed.file_key === 'string' && parsed.file_key) {
57
- return {
58
- text: '',
59
- pendingDownloads: [
60
- {
61
- kind: 'file',
62
- fileKey: parsed.file_key,
63
- fileName: typeof parsed.file_name === 'string' ? parsed.file_name : undefined,
64
- },
65
- ],
66
- }
67
- }
68
- return { text: '', pendingDownloads: [] }
69
- }
70
-
71
- if (msgType === 'post') {
72
- const nodes = (parsed.zh_cn?.content ?? parsed.en_us?.content ?? []) as any[]
73
- const flat = nodes.flat()
74
- const textParts: string[] = []
75
- const downloads: PendingDownload[] = []
76
- for (const node of flat) {
77
- if (!node || typeof node !== 'object') continue
78
- if (node.tag === 'text' || node.tag === 'md') {
79
- const t = node.text ?? node.content ?? ''
80
- if (typeof t === 'string') textParts.push(t)
81
- } else if (node.tag === 'img' && typeof node.image_key === 'string') {
82
- downloads.push({ kind: 'image', fileKey: node.image_key })
83
- } else if (node.tag === 'file' && typeof node.file_key === 'string') {
84
- downloads.push({
85
- kind: 'file',
86
- fileKey: node.file_key,
87
- fileName: typeof node.file_name === 'string' ? node.file_name : undefined,
88
- })
89
- }
90
- }
91
- return { text: textParts.join(''), pendingDownloads: downloads }
92
- }
93
-
94
- return { text: '', pendingDownloads: [] }
95
- }
@@ -1,149 +0,0 @@
1
- /**
2
- * 节流 + mutex + 冲突重刷的通用 flush 调度器
3
- *
4
- * 这是个纯调度原语 —— 不含任何业务逻辑(发送卡片、构造 markdown 等)。
5
- * 实际 flush 工作由构造函数注入的 doFlush 回调负责。
6
- *
7
- * 语义:
8
- * - throttledUpdate(throttleMs) 被流式数据触发,按窗口节流
9
- * - flush() 被 mutex 保护,相同时刻只有一个在跑
10
- * - flush 进行中的新数据标记 needsReflush,API 结束后立即补刷
11
- * - 长间隔(> 2000ms)后的第一次 flush 延迟 300ms 批量,避免抖动
12
- * - complete() 后拒绝所有新 flush
13
- *
14
- * 参考实现: openclaw-lark/src/card/flush-controller.ts
15
- */
16
-
17
- // ---------------------------------------------------------------------------
18
- // Throttle constants
19
- // ---------------------------------------------------------------------------
20
-
21
- export const THROTTLE = {
22
- /** CardKit cardElement.content() 最小间隔 —— 官方为流式设计,可高频 */
23
- CARDKIT_MS: 100,
24
- /** im.message.patch 最小间隔 —— 严格速率限制(230020) */
25
- PATCH_MS: 1500,
26
- /** 长间隔判定阈值。elapsed > 2000ms 触发批量模式 */
27
- LONG_GAP_THRESHOLD_MS: 2000,
28
- /** 长间隔后第一帧的额外延迟,让文本积累更完整 */
29
- BATCH_AFTER_GAP_MS: 300,
30
- } as const
31
-
32
- // ---------------------------------------------------------------------------
33
- // FlushController
34
- // ---------------------------------------------------------------------------
35
-
36
- export class FlushController {
37
- private flushInProgress = false
38
- private flushResolvers: Array<() => void> = []
39
- private needsReflush = false
40
- private pendingFlushTimer: ReturnType<typeof setTimeout> | null = null
41
- private lastUpdateTime = 0
42
- private isCompleted = false
43
- private _cardMessageReady = false
44
-
45
- constructor(private readonly doFlush: () => Promise<void>) {}
46
-
47
- /** 标记完成 —— 当前 flush 跑完后不再接受新的。 */
48
- complete(): void {
49
- this.isCompleted = true
50
- }
51
-
52
- /** 取消任何挂起的延迟 flush 计时器。 */
53
- cancelPendingFlush(): void {
54
- if (this.pendingFlushTimer) {
55
- clearTimeout(this.pendingFlushTimer)
56
- this.pendingFlushTimer = null
57
- }
58
- }
59
-
60
- /** 等待当前正在跑的 flush 结束。没在跑则立即返回。 */
61
- waitForFlush(): Promise<void> {
62
- if (!this.flushInProgress) return Promise.resolve()
63
- return new Promise<void>((resolve) => this.flushResolvers.push(resolve))
64
- }
65
-
66
- /**
67
- * 标记卡片消息是否已发送成功,决定 flush 是否被放行。
68
- *
69
- * 首次变 true 时同步更新 lastUpdateTime,让第一次 throttledUpdate
70
- * 看到一个小的 elapsed,匹配 openclaw 的 "card 创建完立即可刷" 行为。
71
- */
72
- setCardMessageReady(ready: boolean): void {
73
- this._cardMessageReady = ready
74
- if (ready) this.lastUpdateTime = Date.now()
75
- }
76
-
77
- cardMessageReady(): boolean {
78
- return this._cardMessageReady
79
- }
80
-
81
- /**
82
- * 执行一次 flush(mutex 保护 + 冲突重刷)。
83
- *
84
- * 如果已有 flush 在跑,设置 needsReflush,当前 flush 结束后自动补一次。
85
- */
86
- async flush(): Promise<void> {
87
- if (!this.cardMessageReady() || this.flushInProgress || this.isCompleted) {
88
- if (this.flushInProgress && !this.isCompleted) this.needsReflush = true
89
- return
90
- }
91
- this.flushInProgress = true
92
- this.needsReflush = false
93
- // 在 API 调用 **之前** 更新时间戳,防止并发调用者也进入 flush
94
- this.lastUpdateTime = Date.now()
95
- try {
96
- await this.doFlush()
97
- this.lastUpdateTime = Date.now()
98
- } finally {
99
- this.flushInProgress = false
100
- const resolvers = this.flushResolvers
101
- this.flushResolvers = []
102
- for (const resolve of resolvers) resolve()
103
-
104
- // 如果 API 调用期间有新事件进来,立即补一次 flush
105
- if (this.needsReflush && !this.isCompleted && !this.pendingFlushTimer) {
106
- this.needsReflush = false
107
- this.pendingFlushTimer = setTimeout(() => {
108
- this.pendingFlushTimer = null
109
- void this.flush()
110
- }, 0)
111
- }
112
- }
113
- }
114
-
115
- /**
116
- * 节流更新入口。
117
- *
118
- * @param throttleMs - 最小 flush 间隔。CardKit 传 THROTTLE.CARDKIT_MS,
119
- * Patch 降级路径传 THROTTLE.PATCH_MS。
120
- */
121
- async throttledUpdate(throttleMs: number): Promise<void> {
122
- if (!this.cardMessageReady()) return
123
-
124
- const now = Date.now()
125
- const elapsed = now - this.lastUpdateTime
126
-
127
- if (elapsed >= throttleMs) {
128
- this.cancelPendingFlush()
129
- if (elapsed > THROTTLE.LONG_GAP_THRESHOLD_MS) {
130
- // 长间隔批量模式:工具调用 / 推理回来后的第一帧延迟 300ms
131
- // 让文本积累更完整,避免只显示一两个字
132
- this.lastUpdateTime = now
133
- this.pendingFlushTimer = setTimeout(() => {
134
- this.pendingFlushTimer = null
135
- void this.flush()
136
- }, THROTTLE.BATCH_AFTER_GAP_MS)
137
- } else {
138
- await this.flush()
139
- }
140
- } else if (!this.pendingFlushTimer) {
141
- // 在节流窗口内 —— 延迟到窗口结束再刷
142
- const delay = throttleMs - elapsed
143
- this.pendingFlushTimer = setTimeout(() => {
144
- this.pendingFlushTimer = null
145
- void this.flush()
146
- }, delay)
147
- }
148
- }
149
- }