mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Replace legacy im.message.patch streaming path with a Feishu CardKit
Schema 2.0 pipeline mirroring openclaw-lark's implementation. Desktop
now renders real formatted markdown (headings, tables, code blocks)
instead of literal text, and streaming output updates per chat without
cross-talk.
New modules:
- card-errors.ts: structured Lark SDK error parsing for code 230020
(rate limit -> skip frame) and 230099/11310 (table limit -> disable
CardKit streaming and fall back to settings+update at finalize)
- flush-controller.ts: mutex + needsReflush + long-gap batching
throttler for per-chat flush coordination
- cardkit.ts: thin wrapper over client.cardkit.v1.* (card.create +
im.message.{create,reply} + cardElement.content + card.settings +
card.update), with monotonic sequence and CardKitApiError
- streaming-card.ts: per-chat StreamingCard state machine covering
idle -> creating -> streaming -> finalizing -> completed/aborted,
including patch fallback when CardKit create/send fails
index.ts rewrite:
- replaces MessageBuffer/chatStates/buffers maps with a single
streamingCards Map<chatId, StreamingCard>
- content_start{text}/content_delta create or reuse the card; finalize
runs on message_complete; abort renders a red error card
- /clear and other silent commands stay out of the streaming-card path
so they don't leave empty cards
- all command handlers + normal chat are wrapped in the per-chat
enqueue() serial queue, preventing reply reordering when commands
are fired rapidly
- createSessionForChat() now first resets any stale WS session before
calling connectSession, fixing /projects pick_project leaving the
old session bound to the previous workDir
- normal messages pre-create the card before sendUserMessage so users
see a "☁️ 正在思考中..." loading indicator immediately
- content_start{tool_use} no longer finalizes the current card, letting
one user turn's full text stay in a single card
markdown-style.ts:
- default cardVersion changed from 1 to 2 (Schema 2.0)
- headings H2~H6 demoted to H5, H1 to H4, only when source has H1~H3
- Schema 2.0 <br> padding around headings, tables, code blocks
- stripInvalidImageKeys() drops non-img_* image references
- FEISHU_CARD_TABLE_LIMIT=3 + sanitizeTextForCard() wraps 4th+ table
in a fenced code block to avoid 230099/11310
Tests: 182 pass, 0 fail, 383 expect() calls across 6 files. tsc clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
150 lines
5.1 KiB
TypeScript
150 lines
5.1 KiB
TypeScript
/**
|
||
* 节流 + mutex + 冲突重刷的通用 flush 调度器
|
||
*
|
||
* 这是个纯调度原语 —— 不含任何业务逻辑(发送卡片、构造 markdown 等)。
|
||
* 实际 flush 工作由构造函数注入的 doFlush 回调负责。
|
||
*
|
||
* 语义:
|
||
* - throttledUpdate(throttleMs) 被流式数据触发,按窗口节流
|
||
* - flush() 被 mutex 保护,相同时刻只有一个在跑
|
||
* - flush 进行中的新数据标记 needsReflush,API 结束后立即补刷
|
||
* - 长间隔(> 2000ms)后的第一次 flush 延迟 300ms 批量,避免抖动
|
||
* - complete() 后拒绝所有新 flush
|
||
*
|
||
* 参考实现: openclaw-lark/src/card/flush-controller.ts
|
||
*/
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Throttle constants
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export const THROTTLE = {
|
||
/** CardKit cardElement.content() 最小间隔 —— 官方为流式设计,可高频 */
|
||
CARDKIT_MS: 100,
|
||
/** im.message.patch 最小间隔 —— 严格速率限制(230020) */
|
||
PATCH_MS: 1500,
|
||
/** 长间隔判定阈值。elapsed > 2000ms 触发批量模式 */
|
||
LONG_GAP_THRESHOLD_MS: 2000,
|
||
/** 长间隔后第一帧的额外延迟,让文本积累更完整 */
|
||
BATCH_AFTER_GAP_MS: 300,
|
||
} as const
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// FlushController
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export class FlushController {
|
||
private flushInProgress = false
|
||
private flushResolvers: Array<() => void> = []
|
||
private needsReflush = false
|
||
private pendingFlushTimer: ReturnType<typeof setTimeout> | null = null
|
||
private lastUpdateTime = 0
|
||
private isCompleted = false
|
||
private _cardMessageReady = false
|
||
|
||
constructor(private readonly doFlush: () => Promise<void>) {}
|
||
|
||
/** 标记完成 —— 当前 flush 跑完后不再接受新的。 */
|
||
complete(): void {
|
||
this.isCompleted = true
|
||
}
|
||
|
||
/** 取消任何挂起的延迟 flush 计时器。 */
|
||
cancelPendingFlush(): void {
|
||
if (this.pendingFlushTimer) {
|
||
clearTimeout(this.pendingFlushTimer)
|
||
this.pendingFlushTimer = null
|
||
}
|
||
}
|
||
|
||
/** 等待当前正在跑的 flush 结束。没在跑则立即返回。 */
|
||
waitForFlush(): Promise<void> {
|
||
if (!this.flushInProgress) return Promise.resolve()
|
||
return new Promise<void>((resolve) => this.flushResolvers.push(resolve))
|
||
}
|
||
|
||
/**
|
||
* 标记卡片消息是否已发送成功,决定 flush 是否被放行。
|
||
*
|
||
* 首次变 true 时同步更新 lastUpdateTime,让第一次 throttledUpdate
|
||
* 看到一个小的 elapsed,匹配 openclaw 的 "card 创建完立即可刷" 行为。
|
||
*/
|
||
setCardMessageReady(ready: boolean): void {
|
||
this._cardMessageReady = ready
|
||
if (ready) this.lastUpdateTime = Date.now()
|
||
}
|
||
|
||
cardMessageReady(): boolean {
|
||
return this._cardMessageReady
|
||
}
|
||
|
||
/**
|
||
* 执行一次 flush(mutex 保护 + 冲突重刷)。
|
||
*
|
||
* 如果已有 flush 在跑,设置 needsReflush,当前 flush 结束后自动补一次。
|
||
*/
|
||
async flush(): Promise<void> {
|
||
if (!this.cardMessageReady() || this.flushInProgress || this.isCompleted) {
|
||
if (this.flushInProgress && !this.isCompleted) this.needsReflush = true
|
||
return
|
||
}
|
||
this.flushInProgress = true
|
||
this.needsReflush = false
|
||
// 在 API 调用 **之前** 更新时间戳,防止并发调用者也进入 flush
|
||
this.lastUpdateTime = Date.now()
|
||
try {
|
||
await this.doFlush()
|
||
this.lastUpdateTime = Date.now()
|
||
} finally {
|
||
this.flushInProgress = false
|
||
const resolvers = this.flushResolvers
|
||
this.flushResolvers = []
|
||
for (const resolve of resolvers) resolve()
|
||
|
||
// 如果 API 调用期间有新事件进来,立即补一次 flush
|
||
if (this.needsReflush && !this.isCompleted && !this.pendingFlushTimer) {
|
||
this.needsReflush = false
|
||
this.pendingFlushTimer = setTimeout(() => {
|
||
this.pendingFlushTimer = null
|
||
void this.flush()
|
||
}, 0)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 节流更新入口。
|
||
*
|
||
* @param throttleMs - 最小 flush 间隔。CardKit 传 THROTTLE.CARDKIT_MS,
|
||
* Patch 降级路径传 THROTTLE.PATCH_MS。
|
||
*/
|
||
async throttledUpdate(throttleMs: number): Promise<void> {
|
||
if (!this.cardMessageReady()) return
|
||
|
||
const now = Date.now()
|
||
const elapsed = now - this.lastUpdateTime
|
||
|
||
if (elapsed >= throttleMs) {
|
||
this.cancelPendingFlush()
|
||
if (elapsed > THROTTLE.LONG_GAP_THRESHOLD_MS) {
|
||
// 长间隔批量模式:工具调用 / 推理回来后的第一帧延迟 300ms
|
||
// 让文本积累更完整,避免只显示一两个字
|
||
this.lastUpdateTime = now
|
||
this.pendingFlushTimer = setTimeout(() => {
|
||
this.pendingFlushTimer = null
|
||
void this.flush()
|
||
}, THROTTLE.BATCH_AFTER_GAP_MS)
|
||
} else {
|
||
await this.flush()
|
||
}
|
||
} else if (!this.pendingFlushTimer) {
|
||
// 在节流窗口内 —— 延迟到窗口结束再刷
|
||
const delay = throttleMs - elapsed
|
||
this.pendingFlushTimer = setTimeout(() => {
|
||
this.pendingFlushTimer = null
|
||
void this.flush()
|
||
}, delay)
|
||
}
|
||
}
|
||
}
|