From ae586b0d9b19cf598b2f055ba9d57ba3ee6b91bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 11 Apr 2026 21:04:22 +0800 Subject: [PATCH] feat(feishu): rewrite streaming to CardKit Schema 2.0 with proper markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 - 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
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) --- adapters/feishu/__tests__/card-errors.test.ts | 194 +++++++ adapters/feishu/__tests__/cardkit.test.ts | 295 ++++++++++ .../feishu/__tests__/flush-controller.test.ts | 290 ++++++++++ .../feishu/__tests__/markdown-style.test.ts | 375 +++++++++---- .../feishu/__tests__/streaming-card.test.ts | 523 ++++++++++++++++++ adapters/feishu/card-errors.ts | 151 +++++ adapters/feishu/cardkit.ts | 294 ++++++++++ adapters/feishu/flush-controller.ts | 149 +++++ adapters/feishu/index.ts | 363 ++++++------ adapters/feishu/markdown-style.ts | 181 +++++- adapters/feishu/streaming-card.ts | 459 +++++++++++++++ 11 files changed, 2941 insertions(+), 333 deletions(-) create mode 100644 adapters/feishu/__tests__/card-errors.test.ts create mode 100644 adapters/feishu/__tests__/cardkit.test.ts create mode 100644 adapters/feishu/__tests__/flush-controller.test.ts create mode 100644 adapters/feishu/__tests__/streaming-card.test.ts create mode 100644 adapters/feishu/card-errors.ts create mode 100644 adapters/feishu/cardkit.ts create mode 100644 adapters/feishu/flush-controller.ts create mode 100644 adapters/feishu/streaming-card.ts diff --git a/adapters/feishu/__tests__/card-errors.test.ts b/adapters/feishu/__tests__/card-errors.test.ts new file mode 100644 index 00000000..dc958d7c --- /dev/null +++ b/adapters/feishu/__tests__/card-errors.test.ts @@ -0,0 +1,194 @@ +/** + * card-errors 单元测试 + */ + +import { describe, it, expect } from 'bun:test' +import { + CARD_ERROR, + CARD_CONTENT_SUB_ERROR, + extractLarkApiCode, + extractSubCode, + parseCardApiError, + isCardRateLimitError, + isCardTableLimitError, +} from '../card-errors.js' + +describe('extractLarkApiCode', () => { + it('从 err.code 直接提取', () => { + expect(extractLarkApiCode({ code: 230020 })).toBe(230020) + }) + + it('从 err.data.code 提取', () => { + expect(extractLarkApiCode({ data: { code: 230099 } })).toBe(230099) + }) + + it('从 err.response.data.code 提取(Axios 风格)', () => { + expect(extractLarkApiCode({ response: { data: { code: 99991672 } } })).toBe(99991672) + }) + + it('数字字符串被强制转成 number', () => { + expect(extractLarkApiCode({ code: '230020' })).toBe(230020) + }) + + it('三层结构优先级: err.code > err.data.code > err.response.data.code', () => { + const err = { + code: 1, + data: { code: 2 }, + response: { data: { code: 3 } }, + } + expect(extractLarkApiCode(err)).toBe(1) + }) + + it('none → undefined', () => { + expect(extractLarkApiCode({})).toBeUndefined() + expect(extractLarkApiCode(null)).toBeUndefined() + expect(extractLarkApiCode(undefined)).toBeUndefined() + expect(extractLarkApiCode('just a string')).toBeUndefined() + expect(extractLarkApiCode(new Error('plain'))).toBeUndefined() + }) + + it('非有限数字被忽略', () => { + expect(extractLarkApiCode({ code: NaN })).toBeUndefined() + expect(extractLarkApiCode({ code: 'not-a-number' })).toBeUndefined() + }) +}) + +describe('extractSubCode', () => { + it('识别标准的 ErrCode: 11310', () => { + const msg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit' + expect(extractSubCode(msg)).toBe(11310) + }) + + it('无 ErrCode 时返回 null', () => { + expect(extractSubCode('random error message')).toBeNull() + expect(extractSubCode('')).toBeNull() + }) + + it('ErrCode 大小写容错(冒号后多空格)', () => { + expect(extractSubCode('ErrCode: 42')).toBe(42) + }) +}) + +describe('parseCardApiError', () => { + it('从 SDK 风格错误提取完整结构', () => { + const err = { code: 230099, msg: 'ErrCode: 11310; ErrMsg: card table number over limit' } + const parsed = parseCardApiError(err) + expect(parsed).toEqual({ + code: 230099, + subCode: 11310, + errMsg: 'ErrCode: 11310; ErrMsg: card table number over limit', + }) + }) + + it('从 Axios 风格错误(response.data.msg)提取', () => { + const err = { + response: { + data: { + code: 230020, + msg: 'rate limited', + }, + }, + } + const parsed = parseCardApiError(err) + expect(parsed?.code).toBe(230020) + expect(parsed?.errMsg).toBe('rate limited') + expect(parsed?.subCode).toBeNull() + }) + + it('无 code 时返回 null', () => { + expect(parseCardApiError({})).toBeNull() + expect(parseCardApiError(null)).toBeNull() + expect(parseCardApiError('string')).toBeNull() + }) + + it('有 code 无 msg 时 errMsg 为空字符串', () => { + const parsed = parseCardApiError({ code: 230020 }) + expect(parsed).toEqual({ code: 230020, subCode: null, errMsg: '' }) + }) + + it('fallback 到 err.message', () => { + const err = Object.assign(new Error('fallback text'), { code: 230099 }) + const parsed = parseCardApiError(err) + expect(parsed?.errMsg).toBe('fallback text') + }) +}) + +describe('isCardRateLimitError', () => { + it('识别 230020', () => { + expect(isCardRateLimitError({ code: 230020 })).toBe(true) + }) + + it('识别 Axios 风格 230020', () => { + expect(isCardRateLimitError({ response: { data: { code: 230020 } } })).toBe(true) + }) + + it('不匹配其他 code', () => { + expect(isCardRateLimitError({ code: 230099 })).toBe(false) + expect(isCardRateLimitError({ code: 99991672 })).toBe(false) + }) + + it('非错误对象返回 false', () => { + expect(isCardRateLimitError(null)).toBe(false) + expect(isCardRateLimitError({})).toBe(false) + expect(isCardRateLimitError(new Error('random'))).toBe(false) + }) +}) + +describe('isCardTableLimitError', () => { + const validMsg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; ' + + it('严格三条件匹配: code=230099 + subCode=11310 + msg 含 table number over limit', () => { + const err = { code: CARD_ERROR.CARD_CONTENT_FAILED, msg: validMsg } + expect(isCardTableLimitError(err)).toBe(true) + }) + + it('从 Axios 风格的 response.data 匹配', () => { + const err = { + response: { + data: { + code: 230099, + msg: validMsg, + }, + }, + } + expect(isCardTableLimitError(err)).toBe(true) + }) + + it('230099 + 11310 但没有 "table number over limit" 字样 → false(其它元素超限)', () => { + const err = { + code: CARD_ERROR.CARD_CONTENT_FAILED, + msg: 'ErrCode: 11310; ErrMsg: some other element limit; ', + } + expect(isCardTableLimitError(err)).toBe(false) + }) + + it('code 不是 230099 → false', () => { + const err = { code: 230020, msg: validMsg } + expect(isCardTableLimitError(err)).toBe(false) + }) + + it('没有 subCode → false', () => { + const err = { code: 230099, msg: 'card table number over limit (no ErrCode)' } + expect(isCardTableLimitError(err)).toBe(false) + }) + + it('不区分 "table number" 的大小写', () => { + const err = { + code: 230099, + msg: 'ErrCode: 11310; ErrMsg: CARD TABLE NUMBER OVER LIMIT; ', + } + expect(isCardTableLimitError(err)).toBe(true) + }) +}) + +describe('常量值', () => { + it('CARD_ERROR.RATE_LIMITED === 230020', () => { + expect(CARD_ERROR.RATE_LIMITED).toBe(230020) + }) + it('CARD_ERROR.CARD_CONTENT_FAILED === 230099', () => { + expect(CARD_ERROR.CARD_CONTENT_FAILED).toBe(230099) + }) + it('CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT === 11310', () => { + expect(CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT).toBe(11310) + }) +}) diff --git a/adapters/feishu/__tests__/cardkit.test.ts b/adapters/feishu/__tests__/cardkit.test.ts new file mode 100644 index 00000000..bbcc5561 --- /dev/null +++ b/adapters/feishu/__tests__/cardkit.test.ts @@ -0,0 +1,295 @@ +/** + * cardkit.ts 单元测试 + * + * 不调用真实的 Lark API —— 用 mock client 捕获调用参数,验证: + * - 每个函数构造的 payload 结构 + * - 非零 code 响应抛出 CardKitApiError(可被 card-errors 识别) + * - 缺失关键字段时抛错 + * - sequence 正确传递 + */ + +import { describe, it, expect } from 'bun:test' +import { + createCardEntity, + sendCardAsMessage, + streamCardContent, + setCardStreamingMode, + updateCardKitCard, + CardKitApiError, + STREAMING_ELEMENT_ID, +} from '../cardkit.js' +import { isCardRateLimitError, isCardTableLimitError } from '../card-errors.js' + +// --------------------------------------------------------------------------- +// Mock client factory +// --------------------------------------------------------------------------- + +type MockCall = { api: string; args: any } + +function makeMockClient(responses: Record) { + const calls: MockCall[] = [] + const recorder = (api: string, resp: any) => async (args: any) => { + calls.push({ api, args }) + if (typeof resp === 'function') { + return resp(args) + } + return resp + } + const client: any = { + cardkit: { + v1: { + card: { + create: recorder('cardkit.v1.card.create', responses['card.create']), + settings: recorder('cardkit.v1.card.settings', responses['card.settings']), + update: recorder('cardkit.v1.card.update', responses['card.update']), + }, + cardElement: { + content: recorder( + 'cardkit.v1.cardElement.content', + responses['cardElement.content'], + ), + }, + }, + }, + im: { + message: { + create: recorder('im.message.create', responses['im.message.create']), + reply: recorder('im.message.reply', responses['im.message.reply']), + }, + }, + } + return { client, calls } +} + +// --------------------------------------------------------------------------- +// createCardEntity +// --------------------------------------------------------------------------- + +describe('createCardEntity', () => { + it('构造 card_json payload 并返回 card_id', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { + code: 0, + data: { card_id: 'ck_abc_123' }, + }, + }) + const card = { schema: '2.0', body: { elements: [] } } + const id = await createCardEntity(client, card) + + expect(id).toBe('ck_abc_123') + expect(calls.length).toBe(1) + expect(calls[0]!.api).toBe('cardkit.v1.card.create') + expect(calls[0]!.args.data.type).toBe('card_json') + // data.data 应当是 card 的 JSON 字符串 + expect(calls[0]!.args.data.data).toBe(JSON.stringify(card)) + }) + + it('兼容顶层 card_id(某些 SDK 包装层)', async () => { + const { client } = makeMockClient({ + 'card.create': { code: 0, card_id: 'top_level_id' }, + }) + const id = await createCardEntity(client, {}) + expect(id).toBe('top_level_id') + }) + + it('non-zero code 抛 CardKitApiError', async () => { + const { client } = makeMockClient({ + 'card.create': { code: 230099, msg: 'something failed' }, + }) + await expect(createCardEntity(client, {})).rejects.toThrow(CardKitApiError) + }) + + it('code=0 但缺 card_id 抛错', async () => { + const { client } = makeMockClient({ + 'card.create': { code: 0, data: {} }, + }) + await expect(createCardEntity(client, {})).rejects.toThrow(/missing card_id/) + }) +}) + +// --------------------------------------------------------------------------- +// sendCardAsMessage +// --------------------------------------------------------------------------- + +describe('sendCardAsMessage', () => { + it('无 replyTo: 走 im.message.create 使用 chat_id', async () => { + const { client, calls } = makeMockClient({ + 'im.message.create': { data: { message_id: 'om_new_msg_1' } }, + }) + const mid = await sendCardAsMessage(client, 'oc_chat_123', 'ck_id_xyz') + expect(mid).toBe('om_new_msg_1') + expect(calls.length).toBe(1) + expect(calls[0]!.api).toBe('im.message.create') + expect(calls[0]!.args.params.receive_id_type).toBe('chat_id') + expect(calls[0]!.args.data.receive_id).toBe('oc_chat_123') + expect(calls[0]!.args.data.msg_type).toBe('interactive') + // content 格式: {"type":"card","data":{"card_id":"xxx"}} + const parsed = JSON.parse(calls[0]!.args.data.content) + expect(parsed).toEqual({ type: 'card', data: { card_id: 'ck_id_xyz' } }) + }) + + it('有 replyTo: 走 im.message.reply', async () => { + const { client, calls } = makeMockClient({ + 'im.message.reply': { data: { message_id: 'om_reply_1' } }, + }) + const mid = await sendCardAsMessage(client, 'oc_chat_123', 'ck_id_xyz', 'om_parent') + expect(mid).toBe('om_reply_1') + expect(calls.length).toBe(1) + expect(calls[0]!.api).toBe('im.message.reply') + expect(calls[0]!.args.path.message_id).toBe('om_parent') + const parsed = JSON.parse(calls[0]!.args.data.content) + expect(parsed.data.card_id).toBe('ck_id_xyz') + }) + + it('缺 message_id 抛错', async () => { + const { client } = makeMockClient({ + 'im.message.create': { data: {} }, + }) + await expect(sendCardAsMessage(client, 'c', 'ck')).rejects.toThrow( + /missing message_id/, + ) + }) +}) + +// --------------------------------------------------------------------------- +// streamCardContent +// --------------------------------------------------------------------------- + +describe('streamCardContent', () => { + it('构造 content + sequence payload,path 包含 card_id + element_id', async () => { + const { client, calls } = makeMockClient({ + 'cardElement.content': { code: 0 }, + }) + await streamCardContent(client, 'ck_abc', STREAMING_ELEMENT_ID, 'hello', 42) + + expect(calls.length).toBe(1) + expect(calls[0]!.api).toBe('cardkit.v1.cardElement.content') + expect(calls[0]!.args.data).toEqual({ content: 'hello', sequence: 42 }) + expect(calls[0]!.args.path).toEqual({ + card_id: 'ck_abc', + element_id: STREAMING_ELEMENT_ID, + }) + }) + + it('STREAMING_ELEMENT_ID 常量 = "streaming_content"', () => { + expect(STREAMING_ELEMENT_ID).toBe('streaming_content') + }) + + it('230020 响应可被 isCardRateLimitError 识别', async () => { + const { client } = makeMockClient({ + 'cardElement.content': { code: 230020, msg: 'rate limited' }, + }) + try { + await streamCardContent(client, 'ck', 'el', 'x', 1) + expect('should have thrown').toBe('but did not') + } catch (err) { + expect(err).toBeInstanceOf(CardKitApiError) + expect(isCardRateLimitError(err)).toBe(true) + } + }) + + it('230099 + table limit msg 可被 isCardTableLimitError 识别', async () => { + const { client } = makeMockClient({ + 'cardElement.content': { + code: 230099, + msg: 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; ', + }, + }) + try { + await streamCardContent(client, 'ck', 'el', 'x', 1) + expect('should have thrown').toBe('but did not') + } catch (err) { + expect(err).toBeInstanceOf(CardKitApiError) + expect(isCardTableLimitError(err)).toBe(true) + } + }) +}) + +// --------------------------------------------------------------------------- +// setCardStreamingMode +// --------------------------------------------------------------------------- + +describe('setCardStreamingMode', () => { + it('streaming_mode=false + sequence 正确传递', async () => { + const { client, calls } = makeMockClient({ + 'card.settings': { code: 0 }, + }) + await setCardStreamingMode(client, 'ck_xxx', false, 99) + + expect(calls.length).toBe(1) + expect(calls[0]!.api).toBe('cardkit.v1.card.settings') + expect(calls[0]!.args.path).toEqual({ card_id: 'ck_xxx' }) + expect(calls[0]!.args.data.sequence).toBe(99) + // settings 是 JSON 字符串 + const settings = JSON.parse(calls[0]!.args.data.settings) + expect(settings).toEqual({ streaming_mode: false }) + }) + + it('streaming_mode=true 也能工作', async () => { + const { client, calls } = makeMockClient({ + 'card.settings': { code: 0 }, + }) + await setCardStreamingMode(client, 'ck', true, 1) + const settings = JSON.parse(calls[0]!.args.data.settings) + expect(settings).toEqual({ streaming_mode: true }) + }) +}) + +// --------------------------------------------------------------------------- +// updateCardKitCard +// --------------------------------------------------------------------------- + +describe('updateCardKitCard', () => { + it('把 card 包装成 card_json payload + sequence', async () => { + const { client, calls } = makeMockClient({ + 'card.update': { code: 0 }, + }) + const card = { schema: '2.0', body: { elements: [{ tag: 'markdown', content: 'done' }] } } + await updateCardKitCard(client, 'ck_final', card, 100) + + expect(calls.length).toBe(1) + expect(calls[0]!.api).toBe('cardkit.v1.card.update') + expect(calls[0]!.args.path).toEqual({ card_id: 'ck_final' }) + expect(calls[0]!.args.data.sequence).toBe(100) + expect(calls[0]!.args.data.card.type).toBe('card_json') + expect(calls[0]!.args.data.card.data).toBe(JSON.stringify(card)) + }) + + it('非零 code 抛 CardKitApiError', async () => { + const { client } = makeMockClient({ + 'card.update': { code: -1, msg: 'bad card' }, + }) + await expect(updateCardKitCard(client, 'ck', {}, 1)).rejects.toThrow(CardKitApiError) + }) +}) + +// --------------------------------------------------------------------------- +// CardKitApiError +// --------------------------------------------------------------------------- + +describe('CardKitApiError', () => { + it('携带 code 和 msg,可被 parseCardApiError 识别', () => { + const err = new CardKitApiError({ + api: 'card.update', + code: 230020, + msg: 'rate limited', + context: 'seq=5', + }) + expect(err.code).toBe(230020) + expect(err.msg).toBe('rate limited') + expect(err.name).toBe('CardKitApiError') + expect(isCardRateLimitError(err)).toBe(true) + }) + + it('消息包含 api 名和 context', () => { + const err = new CardKitApiError({ + api: 'cardElement.content', + code: 230099, + msg: 'oops', + context: 'seq=3 len=100', + }) + expect(err.message).toContain('cardElement.content') + expect(err.message).toContain('230099') + expect(err.message).toContain('seq=3 len=100') + }) +}) diff --git a/adapters/feishu/__tests__/flush-controller.test.ts b/adapters/feishu/__tests__/flush-controller.test.ts new file mode 100644 index 00000000..3324c0cf --- /dev/null +++ b/adapters/feishu/__tests__/flush-controller.test.ts @@ -0,0 +1,290 @@ +/** + * FlushController 单元测试 + * + * 覆盖: + * - 基础: cardMessageReady gate, complete() 锁死 + * - 节流窗口: 立即 flush / 延迟 flush + * - Mutex: 进行中的 flush 重复调用标记 needsReflush + * - Conflict reflush: API 结束后自动补一次 + * - 长间隔批量: elapsed > 2000ms 后延迟 300ms 再 flush + * - waitForFlush: 等当前 flush 结束 + */ + +import { describe, it, expect } from 'bun:test' +import { FlushController, THROTTLE } from '../flush-controller.js' + +// 创建一个可控的 doFlush —— 返回一个 Promise 可以手动 resolve +function makeControllableFlush() { + const calls: string[] = [] + let resolveCurrent: (() => void) | null = null + let latch: Promise | null = null + + const doFlush = async () => { + calls.push('flush-start') + if (latch) { + await latch + latch = null + } + calls.push('flush-end') + } + + const blockNext = () => { + latch = new Promise((resolve) => { + resolveCurrent = resolve + }) + } + + const unblock = () => { + if (resolveCurrent) { + const r = resolveCurrent + resolveCurrent = null + r() + } + } + + return { doFlush, calls, blockNext, unblock } +} + +async function sleep(ms: number): Promise { + await new Promise((r) => setTimeout(r, ms)) +} + +// --------------------------------------------------------------------------- +// Basic gating +// --------------------------------------------------------------------------- + +describe('FlushController: cardMessageReady gate', () => { + it('在 cardMessageReady=false 时不 flush', async () => { + let count = 0 + const fc = new FlushController(async () => { + count += 1 + }) + await fc.flush() + await fc.throttledUpdate(50) + expect(count).toBe(0) + }) + + it('setCardMessageReady(true) 后 flush 可执行', async () => { + let count = 0 + const fc = new FlushController(async () => { + count += 1 + }) + fc.setCardMessageReady(true) + await fc.flush() + expect(count).toBe(1) + }) + + it('setCardMessageReady(true) 同步初始化 lastUpdateTime —— 刚 ready 时 throttledUpdate 被节流窗口阻挡', async () => { + let count = 0 + const fc = new FlushController(async () => { + count += 1 + }) + fc.setCardMessageReady(true) + // 立即调用 throttledUpdate 500ms 窗口,首次 elapsed≈0 → 进入延迟分支 + await fc.throttledUpdate(500) + // 同步阶段还没到 500ms,不应触发 flush + expect(count).toBe(0) + // 500+ms 后延迟 timer 触发 + await sleep(600) + expect(count).toBe(1) + }) +}) + +describe('FlushController: complete()', () => { + it('complete() 后拒绝新 flush', async () => { + let count = 0 + const fc = new FlushController(async () => { + count += 1 + }) + fc.setCardMessageReady(true) + fc.complete() + await fc.flush() + await fc.throttledUpdate(50) + expect(count).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// Throttle window +// --------------------------------------------------------------------------- + +describe('FlushController: 节流窗口', () => { + it('超过窗口立即 flush', async () => { + let count = 0 + const fc = new FlushController(async () => { + count += 1 + }) + fc.setCardMessageReady(true) + // 手动把 lastUpdateTime 挪远(等同于已过了节流窗口) + await sleep(150) + await fc.throttledUpdate(100) + expect(count).toBe(1) + }) + + it('在窗口内首次调用安排延迟 flush', async () => { + let count = 0 + const fc = new FlushController(async () => { + count += 1 + }) + fc.setCardMessageReady(true) // lastUpdateTime = now + + await fc.throttledUpdate(200) + expect(count).toBe(0) // 延迟中,还没触发 + + await sleep(300) + expect(count).toBe(1) // 200ms 后延迟 timer 触发 + }) + + it('窗口内多次调用复用同一个延迟 timer(不重复 flush)', async () => { + let count = 0 + const fc = new FlushController(async () => { + count += 1 + }) + fc.setCardMessageReady(true) + + await fc.throttledUpdate(200) + await fc.throttledUpdate(200) + await fc.throttledUpdate(200) + await fc.throttledUpdate(200) + + await sleep(300) + expect(count).toBe(1) + }) +}) + +// --------------------------------------------------------------------------- +// Mutex + conflict reflush +// --------------------------------------------------------------------------- + +describe('FlushController: mutex + 冲突重刷', () => { + it('flush 进行中的重复调用不并发执行', async () => { + const { doFlush, calls, blockNext, unblock } = makeControllableFlush() + const fc = new FlushController(doFlush) + fc.setCardMessageReady(true) + + blockNext() + const p1 = fc.flush() // flush-start 后被 latch 卡住 + // 让事件循环走一轮,确保第一次 flush 进入 body + await sleep(10) + // 第二次调用时第一次还没结束 —— 应被 mutex 挡住 + const p2 = fc.flush() + + // 两次 Promise 都已登记,但都还没 end + expect(calls).toEqual(['flush-start']) + + unblock() + await p1 + await p2 + // 第一次跑完后,由于 needsReflush 被标记,会触发一次补刷 + // (conflict reflush 是通过 setTimeout 0 调度的,需要让它跑完) + await sleep(20) + + // 第一次 flush-start + flush-end,然后冲突补刷再一次 start + end + expect(calls).toEqual([ + 'flush-start', 'flush-end', + 'flush-start', 'flush-end', + ]) + }) + + it('flush 进行中的 throttledUpdate 也会触发补刷', async () => { + const { doFlush, calls, blockNext, unblock } = makeControllableFlush() + const fc = new FlushController(doFlush) + fc.setCardMessageReady(true) + + blockNext() + const p1 = fc.flush() + await sleep(10) + + // API 进行中收到新的 update 请求 + await fc.throttledUpdate(10) + + unblock() + await p1 + await sleep(30) + + // 第一次 flush + 冲突补刷 + expect(calls.filter((c) => c === 'flush-end').length).toBe(2) + }) +}) + +// --------------------------------------------------------------------------- +// Long gap batching +// --------------------------------------------------------------------------- + +describe('FlushController: 长间隔批量', () => { + it('elapsed > LONG_GAP_THRESHOLD_MS 时延迟 BATCH_AFTER_GAP_MS 再 flush', async () => { + let count = 0 + let flushAtMs = 0 + const start = Date.now() + const fc = new FlushController(async () => { + count += 1 + flushAtMs = Date.now() - start + }) + fc.setCardMessageReady(true) + + // 等到 elapsed > 2000ms + await sleep(THROTTLE.LONG_GAP_THRESHOLD_MS + 50) + const callAt = Date.now() - start + + await fc.throttledUpdate(THROTTLE.CARDKIT_MS) + // throttledUpdate 同步阶段不应立即 flush(因为走批量分支) + expect(count).toBe(0) + + // 等 BATCH_AFTER_GAP_MS + 余量 + await sleep(THROTTLE.BATCH_AFTER_GAP_MS + 50) + expect(count).toBe(1) + // 实际 flush 时刻至少比 throttledUpdate 调用晚 300ms + expect(flushAtMs - callAt).toBeGreaterThanOrEqual(THROTTLE.BATCH_AFTER_GAP_MS - 20) + }) +}) + +// --------------------------------------------------------------------------- +// waitForFlush +// --------------------------------------------------------------------------- + +describe('FlushController: waitForFlush', () => { + it('没在 flush 时立即返回', async () => { + const fc = new FlushController(async () => {}) + fc.setCardMessageReady(true) + const start = Date.now() + await fc.waitForFlush() + expect(Date.now() - start).toBeLessThan(10) + }) + + it('有 flush 在跑时等它结束', async () => { + const { doFlush, blockNext, unblock } = makeControllableFlush() + const fc = new FlushController(doFlush) + fc.setCardMessageReady(true) + + blockNext() + const p1 = fc.flush() + await sleep(10) + + let resolved = false + const waiter = fc.waitForFlush().then(() => { + resolved = true + }) + await sleep(20) + expect(resolved).toBe(false) + + unblock() + await p1 + await waiter + expect(resolved).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// 常量合理性 +// --------------------------------------------------------------------------- + +describe('FlushController: THROTTLE 常量', () => { + it('CARDKIT_MS=100, PATCH_MS=1500', () => { + expect(THROTTLE.CARDKIT_MS).toBe(100) + expect(THROTTLE.PATCH_MS).toBe(1500) + }) + it('LONG_GAP_THRESHOLD_MS=2000, BATCH_AFTER_GAP_MS=300', () => { + expect(THROTTLE.LONG_GAP_THRESHOLD_MS).toBe(2000) + expect(THROTTLE.BATCH_AFTER_GAP_MS).toBe(300) + }) +}) diff --git a/adapters/feishu/__tests__/markdown-style.test.ts b/adapters/feishu/__tests__/markdown-style.test.ts index 0b4b98cc..26fcf1d8 100644 --- a/adapters/feishu/__tests__/markdown-style.test.ts +++ b/adapters/feishu/__tests__/markdown-style.test.ts @@ -1,139 +1,318 @@ /** - * optimizeMarkdownForFeishu 单元测试 + * markdown-style 单元测试 * * 覆盖: - * - H1~H3 降级 (workaround Feishu tag:'markdown' H1~H3 渲染异常) - * - 代码块保护(代码块内的 # 不能被降级) + * - 标题降级 (H1~H3 workaround) + * - 代码块保护 * - 空行压缩 - * - 边界场景: 无标题不动、全代码块、H4+ 不触发降级、混合内容 + * - Schema 2.0: 连续标题/表格/代码块
间距 + * - stripInvalidImageKeys + * - sanitizeTextForCard (表格数限制) + * - findMarkdownTablesOutsideCodeBlocks */ import { describe, it, expect } from 'bun:test' -import { optimizeMarkdownForFeishu } from '../markdown-style.js' +import { + optimizeMarkdownForFeishu, + sanitizeTextForCard, + findMarkdownTablesOutsideCodeBlocks, + FEISHU_CARD_TABLE_LIMIT, +} from '../markdown-style.js' + +// 默认 cardVersion=2 的 shortcut +const opt = (text: string, v?: number) => optimizeMarkdownForFeishu(text, v) + +// --------------------------------------------------------------------------- +// 标题降级 +// --------------------------------------------------------------------------- describe('optimizeMarkdownForFeishu: 标题降级', () => { - it('H1 → H4', () => { - const out = optimizeMarkdownForFeishu('# Title') - expect(out).toBe('#### Title') + it('H1 → H4 (cardVersion=1 简化检查)', () => { + expect(opt('# Title', 1)).toBe('#### Title') }) - it('H2 → H5', () => { - const out = optimizeMarkdownForFeishu('## Title') - expect(out).toBe('##### Title') + it('H2 → H5 (cardVersion=1)', () => { + expect(opt('## Title', 1)).toBe('##### Title') }) - it('H3 → H5', () => { - const out = optimizeMarkdownForFeishu('### Title') - expect(out).toBe('##### Title') + it('H3 → H5 (cardVersion=1)', () => { + expect(opt('### Title', 1)).toBe('##### Title') }) - it('混合 H1+H2+H3 全部降级', () => { - const input = '# H1\n## H2\n### H3' - const out = optimizeMarkdownForFeishu(input) - expect(out).toBe('#### H1\n##### H2\n##### H3') + it('混合 H1+H2+H3 全部降级 (cardVersion=1)', () => { + expect(opt('# H1\n## H2\n### H3', 1)).toBe('#### H1\n##### H2\n##### H3') }) - it('已存在的 H4 在没有 H1~H3 时原样保留(不触发降级管道)', () => { - // 触发条件是原文必须有 H1~H3。纯 H4 文档原样返回。 - const input = '#### Already H4' - const out = optimizeMarkdownForFeishu(input) - expect(out).toBe('#### Already H4') + it('纯 H4 文档不触发降级 (cardVersion=1)', () => { + // 触发条件: 原文必须有 H1~H3 + expect(opt('#### Already H4', 1)).toBe('#### Already H4') }) - it('H5 在没有 H1~H3 时保留', () => { - const input = '##### Already H5' - const out = optimizeMarkdownForFeishu(input) - expect(out).toBe('##### Already H5') + it('同时存在 H1 和 H4: H1→H4, 原 H4 → H5 (cardVersion=1)', () => { + expect(opt('# Top\n#### Sub', 1)).toBe('#### Top\n##### Sub') }) - it('同时存在 H1 和 H4: H1→H4,原 H4 被规则降级为 H5', () => { - // H4~H6 也走 #{2,6} → H5 的管道(因为原文有 H1) - const input = '# Top\n#### Sub' - const out = optimizeMarkdownForFeishu(input) - expect(out).toBe('#### Top\n##### Sub') + it('# 后必须有空格才算标题', () => { + expect(opt('#notaheading', 1)).toBe('#notaheading') }) - it('标题文本前必须有空格才算标题(#xxx 不是标题)', () => { - const input = '#notaheading' - const out = optimizeMarkdownForFeishu(input) - expect(out).toBe('#notaheading') - }) - - it('降级顺序: 不会把 # 先降成 ####,然后再被 #{2,6} 吃成 #####', () => { - // 这是 openclaw-lark 源码里的关键注释 —— 顺序不能颠倒 - const out = optimizeMarkdownForFeishu('# Top') - expect(out).toBe('#### Top') // 必须恰好是 4 个 #,不是 5 个 + it('顺序保证: # 降成 #### 后不会被 #{2,6} 再次吃成 #####', () => { + // openclaw-lark 源码里的关键注释:顺序不能颠倒 + expect(opt('# Top', 1)).toBe('#### Top') }) it('无标题文本原样返回', () => { - const input = 'just some plain text\nwith a newline' - const out = optimizeMarkdownForFeishu(input) - expect(out).toBe(input) + expect(opt('just plain text', 1)).toBe('just plain text') + }) + + it('默认 cardVersion=2 下也能正确降级标题', () => { + const out = opt('# Title') + expect(out).toContain('#### Title') + expect(out).not.toMatch(/^# Title$/m) }) }) +// --------------------------------------------------------------------------- +// 代码块保护 +// --------------------------------------------------------------------------- + describe('optimizeMarkdownForFeishu: 代码块保护', () => { - it('代码块内的 # 不会被降级', () => { + it('代码块内的 # 不被降级 (cardVersion=1)', () => { const input = '```\n# not a heading\n## also not\n```' - const out = optimizeMarkdownForFeishu(input) - expect(out).toBe(input) + expect(opt(input, 1)).toBe(input) }) - it('有外部标题时,代码块内的 # 仍受保护', () => { + it('外部 H1 降级,代码块内 # 保持 (cardVersion=1)', () => { const input = '# Real heading\n\n```\n# inside code\n```' - const out = optimizeMarkdownForFeishu(input) - expect(out).toBe('#### Real heading\n\n```\n# inside code\n```') + expect(opt(input, 1)).toBe('#### Real heading\n\n```\n# inside code\n```') }) - it('语言标记的 fenced 代码块也受保护', () => { + it('语言标记的 fenced 代码块也受保护 (cardVersion=1)', () => { const input = '## Section\n\n```python\n# python comment\n### not a heading\n```' - const out = optimizeMarkdownForFeishu(input) - expect(out).toBe('##### Section\n\n```python\n# python comment\n### not a heading\n```') + expect(opt(input, 1)).toBe('##### Section\n\n```python\n# python comment\n### not a heading\n```') }) - it('多个代码块按顺序保护与还原', () => { + it('多个代码块按顺序保护与还原 (cardVersion=1)', () => { const input = '# A\n```\n# b1\n```\n## C\n```\n### b2\n```' - const out = optimizeMarkdownForFeishu(input) - expect(out).toBe('#### A\n```\n# b1\n```\n##### C\n```\n### b2\n```') + expect(opt(input, 1)).toBe('#### A\n```\n# b1\n```\n##### C\n```\n### b2\n```') }) - it('全文只有代码块时原样返回', () => { - const input = '```\n# in code\n## also in code\n```' - const out = optimizeMarkdownForFeishu(input) - expect(out).toBe(input) + it('默认 cardVersion=2 下代码块内 # 仍受保护(语义断言)', () => { + const out = opt('# Heading\n\n```\n# inside\n```') + expect(out).toContain('#### Heading') // 外部 H1 降级 + expect(out).toContain('# inside') // 代码块内保留 + expect(out).toContain('```') // fence 保留 }) }) +// --------------------------------------------------------------------------- +// 空行压缩 +// --------------------------------------------------------------------------- + describe('optimizeMarkdownForFeishu: 空行压缩', () => { - it('3 个换行 → 2 个', () => { - const input = 'line1\n\n\nline2' - const out = optimizeMarkdownForFeishu(input) - expect(out).toBe('line1\n\nline2') + it('3 个换行 → 2 个 (cardVersion=1)', () => { + expect(opt('line1\n\n\nline2', 1)).toBe('line1\n\nline2') }) - it('5 个换行 → 2 个', () => { - const input = 'line1\n\n\n\n\nline2' - const out = optimizeMarkdownForFeishu(input) - expect(out).toBe('line1\n\nline2') + it('5 个换行 → 2 个 (cardVersion=1)', () => { + expect(opt('line1\n\n\n\n\nline2', 1)).toBe('line1\n\nline2') }) - it('2 个换行保留', () => { - const input = 'line1\n\nline2' - const out = optimizeMarkdownForFeishu(input) - expect(out).toBe('line1\n\nline2') + it('2 个换行保留 (cardVersion=1)', () => { + expect(opt('line1\n\nline2', 1)).toBe('line1\n\nline2') }) - it('空行压缩不影响代码块内的连续换行', () => { - // 代码块内的换行应当完全保留 + it('代码块内部连续换行被保留 (cardVersion=1)', () => { const input = '# Title\n\n```\nline1\n\n\nline2\n```' - const out = optimizeMarkdownForFeishu(input) - expect(out).toBe('#### Title\n\n```\nline1\n\n\nline2\n```') + expect(opt(input, 1)).toBe('#### Title\n\n```\nline1\n\n\nline2\n```') }) }) -describe('optimizeMarkdownForFeishu: 真实场景', () => { - it('复现 screenshot 里的项目结构报告场景', () => { - // 用户 screenshot 里的真实 markdown 片段 +// --------------------------------------------------------------------------- +// Schema 2.0:
间距 +// --------------------------------------------------------------------------- + +describe('optimizeMarkdownForFeishu: Schema 2.0
间距', () => { + it('cardVersion=2 默认在代码块前后加
', () => { + const out = opt('text\n\n```\ncode\n```') + // 代码块前后应包裹
+ expect(out).toContain('
\n```') + expect(out).toContain('```\n
') + }) + + it('cardVersion=1 代码块前后不加
', () => { + const out = opt('text\n\n```\ncode\n```', 1) + expect(out).not.toContain('
') + }) + + it('cardVersion=2 连续标题之间加
', () => { + const out = opt('# A\n# B') + // H1 降级为 H4,之间插入
+ expect(out).toMatch(/#### A\n
\n#### B/) + }) + + it('cardVersion=2 表格前后加
', () => { + const input = 'text before\n\n| col1 | col2 |\n|------|------|\n| v1 | v2 |\n\ntext after' + const out = opt(input) + // 表格前:
紧贴文本行(规则 3e 压缩多余空行) + expect(out).toMatch(/text before\n
\n\| col1/) + // 表格后:
跟两个换行到下一段文本 + expect(out).toMatch(/\| v1 \| v2 \|\n
\n\ntext after/) + }) + + it('cardVersion=1 表格前后不加
', () => { + const input = 'text before\n\n| col1 | col2 |\n|------|------|\n| v1 | v2 |\n\ntext after' + const out = opt(input, 1) + expect(out).not.toContain('
') + }) + + it('代码块内的 | 不被当表格处理 (Schema 2.0)', () => { + const input = '```\n| in code | not a table |\n|---|---|\n```' + const out = opt(input) + // 代码块本体应完整保留 + expect(out).toContain('| in code | not a table |') + // 代码块外应该没有出现表格 br 标记(因为代码块内不算表格) + // 代码块本身会被
包裹(Schema 2.0)但不会在 | 周围单独加
+ expect(out).toMatch(/
\n```\n\| in code/) + }) +}) + +// --------------------------------------------------------------------------- +// stripInvalidImageKeys +// --------------------------------------------------------------------------- + +describe('optimizeMarkdownForFeishu: stripInvalidImageKeys', () => { + it('img_* 图片 key 保留', () => { + const out = opt('前缀 ![alt](img_abc123) 后缀') + expect(out).toContain('![alt](img_abc123)') + }) + + it('http:// URL 图片被删除', () => { + const out = opt('前缀 ![alt](http://example.com/img.png) 后缀') + expect(out).toBe('前缀 后缀') + }) + + it('https:// URL 图片被删除', () => { + const out = opt('![a](https://x.y/z.jpg)') + expect(out).toBe('') + }) + + it('本地路径被删除', () => { + const out = opt('![a](/Users/me/pic.png)') + expect(out).toBe('') + }) + + it('无图片文本原样', () => { + expect(opt('no images here', 1)).toBe('no images here') + }) + + it('混合: img_ 保留,URL 删除', () => { + const out = opt('![keep](img_good) 和 ![drop](http://bad.com/x.png)') + expect(out).toContain('![keep](img_good)') + expect(out).not.toContain('bad.com') + expect(out).not.toContain('![drop]') + }) +}) + +// --------------------------------------------------------------------------- +// findMarkdownTablesOutsideCodeBlocks +// --------------------------------------------------------------------------- + +describe('findMarkdownTablesOutsideCodeBlocks', () => { + it('识别单张表格', () => { + const text = '| a | b |\n|---|---|\n| 1 | 2 |' + const matches = findMarkdownTablesOutsideCodeBlocks(text) + expect(matches.length).toBe(1) + expect(matches[0]!.raw).toContain('| a | b |') + }) + + it('识别多张表格', () => { + const text = + '| a | b |\n|---|---|\n| 1 | 2 |\n\ntext\n\n| x | y |\n|---|---|\n| 3 | 4 |' + const matches = findMarkdownTablesOutsideCodeBlocks(text) + expect(matches.length).toBe(2) + }) + + it('代码块内的 | 不被算作表格', () => { + const text = '```\n| in | code |\n|---|---|\n| 1 | 2 |\n```' + const matches = findMarkdownTablesOutsideCodeBlocks(text) + expect(matches.length).toBe(0) + }) + + it('代码块 + 外部表格: 只识别外部的', () => { + const text = + '```\n| in | code |\n|---|---|\n| 1 | 2 |\n```\n\n| real | table |\n|---|---|\n| a | b |' + const matches = findMarkdownTablesOutsideCodeBlocks(text) + expect(matches.length).toBe(1) + expect(matches[0]!.raw).toContain('real') + }) + + it('无表格文本返回空数组', () => { + expect(findMarkdownTablesOutsideCodeBlocks('just text').length).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// sanitizeTextForCard +// --------------------------------------------------------------------------- + +describe('sanitizeTextForCard: 表格数量限制', () => { + function makeTable(label: string): string { + return `| ${label} h1 | h2 |\n|---|---|\n| v1 | v2 |` + } + + it('表格数 ≤ 3 时原样返回', () => { + const text = [makeTable('A'), makeTable('B'), makeTable('C')].join('\n\n') + expect(sanitizeTextForCard(text)).toBe(text) + }) + + it('恰好 3 张表格原样返回', () => { + const text = [makeTable('A'), makeTable('B'), makeTable('C')].join('\n\n') + const matches = findMarkdownTablesOutsideCodeBlocks(text) + expect(matches.length).toBe(3) + expect(sanitizeTextForCard(text)).toBe(text) + }) + + it('4 张表格: 前 3 张保留,第 4 张包裹成 code block', () => { + const text = [makeTable('A'), makeTable('B'), makeTable('C'), makeTable('D')].join('\n\n') + const out = sanitizeTextForCard(text) + // 前 3 张表格原样 + expect(out).toContain(makeTable('A')) + expect(out).toContain(makeTable('B')) + expect(out).toContain(makeTable('C')) + // 第 4 张被包裹 + expect(out).toContain('```\n' + makeTable('D') + '\n```') + }) + + it('自定义 limit=1: 第 1 张保留,之后全部包裹', () => { + const text = [makeTable('A'), makeTable('B'), makeTable('C')].join('\n\n') + const out = sanitizeTextForCard(text, 1) + expect(out).toContain(makeTable('A')) + expect(out).toContain('```\n' + makeTable('B') + '\n```') + expect(out).toContain('```\n' + makeTable('C') + '\n```') + }) + + it('limit=0: 全部包裹', () => { + const text = makeTable('Solo') + const out = sanitizeTextForCard(text, 0) + expect(out).toContain('```\n' + makeTable('Solo') + '\n```') + }) + + it('无表格原样返回', () => { + expect(sanitizeTextForCard('no tables here')).toBe('no tables here') + }) + + it('FEISHU_CARD_TABLE_LIMIT 默认值 = 3', () => { + expect(FEISHU_CARD_TABLE_LIMIT).toBe(3) + }) +}) + +// --------------------------------------------------------------------------- +// 边界与真实场景 +// --------------------------------------------------------------------------- + +describe('optimizeMarkdownForFeishu: 边界与真实场景', () => { + it('screenshot 里的 OpenCutSkill 项目结构报告', () => { const input = `## OpenCutSkill 项目架构概览 ### 1. 项目定位 @@ -148,45 +327,27 @@ opencutskill/ ├── core/ └── tests/ \`\`\`` - - const out = optimizeMarkdownForFeishu(input) - - // 所有 H2~H3 应该被降级为 H5 + const out = opt(input) + // 所有 H2~H3 应被降级为 H5 expect(out).toContain('##### OpenCutSkill 项目架构概览') expect(out).toContain('##### 1. 项目定位') expect(out).toContain('##### 2. 模块结构') // 代码块内容原封不动 expect(out).toContain('opencutskill/') expect(out).toContain('├── cli/') - // 代码块围栏也原样 - expect(out).toContain('```\nopencutskill/') - // 原始的 ## 字面量不应残留 + // 原始 ## 字面量不残留 expect(out).not.toMatch(/^## OpenCutSkill/m) expect(out).not.toMatch(/^### 1\./m) + // 代码块前后有
(Schema 2.0 默认) + expect(out).toContain('
\n```') + expect(out).toContain('```\n
') }) - it('错误输入 fallback 到原文不抛错', () => { - // 即使输入是奇怪的字符串也不应抛错 - const weird = '\u0000\uFFFF```unclosed' - expect(() => optimizeMarkdownForFeishu(weird)).not.toThrow() + it('异常输入 fallback 到原文不抛错', () => { + expect(() => opt('\u0000\uFFFF```unclosed')).not.toThrow() }) it('空字符串返回空字符串', () => { - expect(optimizeMarkdownForFeishu('')).toBe('') - }) -}) - -describe('optimizeMarkdownForFeishu: cardVersion 参数', () => { - it('cardVersion=1 (默认) 不加段落间距
', () => { - const input = '# A\n## B' - const out = optimizeMarkdownForFeishu(input) - expect(out).not.toContain('
') - }) - - it('cardVersion=2 在连续降级标题之间加
', () => { - const input = '# A\n# B' - const out = optimizeMarkdownForFeishu(input, 2) - // 两个 H1 都降级为 H4,之间插入
- expect(out).toContain('#### A\n
\n#### B') + expect(opt('')).toBe('') }) }) diff --git a/adapters/feishu/__tests__/streaming-card.test.ts b/adapters/feishu/__tests__/streaming-card.test.ts new file mode 100644 index 00000000..9a44cd76 --- /dev/null +++ b/adapters/feishu/__tests__/streaming-card.test.ts @@ -0,0 +1,523 @@ +/** + * StreamingCard 生命周期测试 + * + * 用 mock Lark client 覆盖: + * - ensureCreated: 成功路径 / 降级路径 + * - appendText: 累积 + 触发 throttled flush + * - finalize: settings(false) + update 顺序、sequence 单调递增 + * - abort: 渲染错误卡片 + * - 230020 → 跳帧 + * - 230099 table limit → 禁用流式,finalize 时仍走 CardKit + * - 纯 patch fallback 路径 + */ + +import { describe, it, expect, beforeEach } from 'bun:test' +import { + StreamingCard, + buildInitialStreamingCard, + buildRenderedCard, + buildErrorCard, +} from '../streaming-card.js' +import { STREAMING_ELEMENT_ID } from '../cardkit.js' + +// --------------------------------------------------------------------------- +// Mock client +// --------------------------------------------------------------------------- + +type ApiCall = { api: string; args: any } + +type MockBehavior = { + 'card.create'?: any | ((args: any) => any) + 'card.settings'?: any | ((args: any) => any) + 'card.update'?: any | ((args: any) => any) + 'cardElement.content'?: any | ((args: any, callIdx: number) => any) + 'im.message.create'?: any | ((args: any) => any) + 'im.message.reply'?: any | ((args: any) => any) + 'im.message.patch'?: any | ((args: any, callIdx: number) => any) +} + +function makeMockClient(behavior: MockBehavior = {}) { + const calls: ApiCall[] = [] + let contentCallIdx = 0 + let patchCallIdx = 0 + + function handle(api: string, resp: any, args: any, idx?: number): any { + calls.push({ api, args }) + if (typeof resp === 'function') return resp(args, idx ?? 0) + return resp + } + + const client: any = { + cardkit: { + v1: { + card: { + create: async (args: any) => + handle('cardkit.v1.card.create', behavior['card.create'] ?? { + code: 0, data: { card_id: 'ck_default' }, + }, args), + settings: async (args: any) => + handle('cardkit.v1.card.settings', behavior['card.settings'] ?? { code: 0 }, args), + update: async (args: any) => + handle('cardkit.v1.card.update', behavior['card.update'] ?? { code: 0 }, args), + }, + cardElement: { + content: async (args: any) => { + const idx = contentCallIdx++ + return handle('cardkit.v1.cardElement.content', + behavior['cardElement.content'] ?? { code: 0 }, args, idx) + }, + }, + }, + }, + im: { + message: { + create: async (args: any) => + handle('im.message.create', behavior['im.message.create'] ?? { + data: { message_id: 'om_default' }, + }, args), + reply: async (args: any) => + handle('im.message.reply', behavior['im.message.reply'] ?? { + data: { message_id: 'om_reply_default' }, + }, args), + patch: async (args: any) => { + const idx = patchCallIdx++ + return handle('im.message.patch', behavior['im.message.patch'] ?? { code: 0 }, args, idx) + }, + }, + }, + } + return { client, calls } +} + +async function sleep(ms: number) { + await new Promise((r) => setTimeout(r, ms)) +} + +// --------------------------------------------------------------------------- +// Card JSON builders +// --------------------------------------------------------------------------- + +describe('buildInitialStreamingCard', () => { + it('Schema 2.0 + streaming_mode + element_id', () => { + const card = buildInitialStreamingCard() as any + expect(card.schema).toBe('2.0') + expect(card.config.streaming_mode).toBe(true) + // 第二个元素是空的 streaming_content 目标(loading 在首位以避免顶部空 padding) + const streaming = card.body.elements[1] + expect(streaming.tag).toBe('markdown') + expect(streaming.content).toBe('') + expect(streaming.element_id).toBe(STREAMING_ELEMENT_ID) + }) + + it('loading 提示元素在首位(避免空 streaming_content 挤出顶部 padding)', () => { + const card = buildInitialStreamingCard() as any + const elements = card.body.elements as any[] + expect(elements.length).toBe(2) + const loading = elements[0] + expect(loading.tag).toBe('markdown') + expect(loading.content).toContain('正在思考中') + expect(loading.text_size).toBe('notation') + // loading 元素不能有 element_id(那是给 streaming_content 独占的) + expect(loading.element_id).toBeUndefined() + }) +}) + +describe('buildRenderedCard', () => { + it('Schema 2.0, 无 streaming_mode, 单 markdown 元素', () => { + const card = buildRenderedCard('hello world') as any + expect(card.schema).toBe('2.0') + expect(card.config.streaming_mode).toBeUndefined() + expect(card.body.elements.length).toBe(1) + const el = card.body.elements[0] + expect(el.tag).toBe('markdown') + expect(el.content).toBe('hello world') + // 最终卡无需 element_id + expect(el.element_id).toBeUndefined() + }) + + it('空字符串保底为单空格', () => { + const card = buildRenderedCard('') as any + expect(card.body.elements[0].content).toBe(' ') + }) +}) + +describe('buildErrorCard', () => { + it('红色 header + markdown body', () => { + const card = buildErrorCard('oops') as any + expect((card.header as any).template).toBe('red') + expect((card.header as any).title.content).toContain('出错') + expect(card.body.elements[0].content).toBe('oops') + }) +}) + +// --------------------------------------------------------------------------- +// StreamingCard lifecycle +// --------------------------------------------------------------------------- + +describe('StreamingCard: ensureCreated (CardKit 主路径)', () => { + it('依次调用 card.create + im.message.create,sequence=1', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_main_1' } }, + 'im.message.create': { data: { message_id: 'om_main_1' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'oc_chat_1' }) + await sc.ensureCreated() + + expect(sc._getPhase()).toBe('streaming') + expect(sc._getCardId()).toBe('ck_main_1') + expect(sc._getMessageId()).toBe('om_main_1') + expect(sc._getSequence()).toBe(1) + expect(sc._isCardKitStreamActive()).toBe(true) + + expect(calls[0]!.api).toBe('cardkit.v1.card.create') + expect(calls[1]!.api).toBe('im.message.create') + + // 初始卡 JSON 包含 streaming_mode 和 element_id + const cardJson = JSON.parse(calls[0]!.args.data.data) + expect(cardJson.schema).toBe('2.0') + expect(cardJson.config.streaming_mode).toBe(true) + // loading 元素在首位,streaming_content 占位在第二 + expect(cardJson.body.elements[1].element_id).toBe(STREAMING_ELEMENT_ID) + + // IM message 引用 card_id + const content = JSON.parse(calls[1]!.args.data.content) + expect(content).toEqual({ type: 'card', data: { card_id: 'ck_main_1' } }) + }) + + it('幂等: 重复调用 ensureCreated 不重复创建', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_1' } }, + 'im.message.create': { data: { message_id: 'om_1' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + await sc.ensureCreated() + await sc.ensureCreated() + // 只一次 create + 一次 send + const createCalls = calls.filter((c) => c.api === 'cardkit.v1.card.create') + const sendCalls = calls.filter((c) => c.api === 'im.message.create') + expect(createCalls.length).toBe(1) + expect(sendCalls.length).toBe(1) + }) + + it('replyToMessageId 走 im.message.reply 而非 create', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck' } }, + 'im.message.reply': { data: { message_id: 'om_reply' } }, + }) + const sc = new StreamingCard({ + larkClient: client, + chatId: 'c', + replyToMessageId: 'om_parent', + }) + await sc.ensureCreated() + expect(calls.some((c) => c.api === 'im.message.reply')).toBe(true) + expect(calls.some((c) => c.api === 'im.message.create')).toBe(false) + expect(sc._getMessageId()).toBe('om_reply') + }) +}) + +describe('StreamingCard: ensureCreated (fallback 降级路径)', () => { + it('CardKit create 失败 → 直发 Schema 2.0 卡 + patch 模式', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 99991672, msg: 'permission denied' }, + 'im.message.create': { data: { message_id: 'om_fb' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + expect(sc._getPhase()).toBe('streaming') + expect(sc._getCardId()).toBeNull() + expect(sc._getMessageId()).toBe('om_fb') + expect(sc._isCardKitStreamActive()).toBe(false) + + // fallback 发送的是 Schema 2.0 interactive 卡 + const createCall = calls.find((c) => c.api === 'im.message.create') + expect(createCall).toBeDefined() + expect(createCall!.args.data.msg_type).toBe('interactive') + const cardContent = JSON.parse(createCall!.args.data.content) + expect(cardContent.schema).toBe('2.0') + }) + + it('CardKit send 失败(create 成功但 im.message.create 失败)也能降级', async () => { + let sendCallCount = 0 + const { client } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck' } }, + 'im.message.create': () => { + sendCallCount++ + if (sendCallCount === 1) throw new Error('send failed') + return { data: { message_id: 'om_fb2' } } + }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + expect(sc._getPhase()).toBe('streaming') + expect(sc._getCardId()).toBeNull() + expect(sc._getMessageId()).toBe('om_fb2') + }) + + it('降级发送也失败 → aborted + throw', async () => { + const { client } = makeMockClient({ + 'card.create': { code: 99991672 }, + 'im.message.create': () => { + throw new Error('really broken') + }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await expect(sc.ensureCreated()).rejects.toThrow() + expect(sc._getPhase()).toBe('aborted') + }) +}) + +// --------------------------------------------------------------------------- +// appendText + flush +// --------------------------------------------------------------------------- + +describe('StreamingCard: appendText + flush', () => { + it('accumulated 文本写入 cardElement.content,sequence 单调递增', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_stream' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + // 第一次 appendText 进入节流窗口(刚 ready,lastUpdateTime 还新) + sc.appendText('Hello ') + sc.appendText('world') + + // 节流窗口 100ms + 余量 + await sleep(150) + + const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content') + expect(contentCalls.length).toBeGreaterThan(0) + // 最后一次 flush 的内容应包含完整累积文本 + const lastCall = contentCalls[contentCalls.length - 1]! + expect(lastCall.args.data.content).toContain('Hello world') + expect(lastCall.args.path.element_id).toBe(STREAMING_ELEMENT_ID) + // sequence 严格单调递增 + const seqs = contentCalls.map((c) => c.args.data.sequence) + for (let i = 1; i < seqs.length; i++) { + expect(seqs[i]).toBeGreaterThan(seqs[i - 1]!) + } + }) + + it('内容未变化时不重复 flush(基于 lastFlushedText 对比)', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + + sc.appendText('same') + await sleep(150) + + // 强制再跑一次 flush(无新文本) + await sc._getFlushController().flush() + + const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content') + // 应该只有一次 content 调用 + expect(contentCalls.length).toBe(1) + }) + + it('completed 之后的 appendText 被忽略', async () => { + const { client } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + await sc.finalize() + sc.appendText('ignored') + expect(sc._getAccumulatedText()).toBe('') + }) +}) + +// --------------------------------------------------------------------------- +// finalize +// --------------------------------------------------------------------------- + +describe('StreamingCard: finalize', () => { + it('CardKit 路径: settings(false) + card.update,sequence 连续递增', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_final' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + sc.appendText('# Title\n\nBody') + await sleep(150) + const contentSeqs = calls + .filter((c) => c.api === 'cardkit.v1.cardElement.content') + .map((c) => c.args.data.sequence) + const lastContentSeq = contentSeqs[contentSeqs.length - 1] ?? 1 + + await sc.finalize() + + expect(sc._getPhase()).toBe('completed') + + const settingsCalls = calls.filter((c) => c.api === 'cardkit.v1.card.settings') + const updateCalls = calls.filter((c) => c.api === 'cardkit.v1.card.update') + expect(settingsCalls.length).toBe(1) + expect(updateCalls.length).toBe(1) + + const settingsSeq = settingsCalls[0]!.args.data.sequence + const updateSeq = updateCalls[0]!.args.data.sequence + expect(settingsSeq).toBeGreaterThan(lastContentSeq) + expect(updateSeq).toBeGreaterThan(settingsSeq) + + // settings 关闭 streaming_mode + const settings = JSON.parse(settingsCalls[0]!.args.data.settings) + expect(settings.streaming_mode).toBe(false) + + // update 卡内容是预处理后的 markdown + const finalCardJson = JSON.parse(updateCalls[0]!.args.data.card.data) + const finalContent = finalCardJson.body.elements[0].content + // H1 被降级为 H4 + expect(finalContent).toContain('#### Title') + expect(finalContent).toContain('Body') + }) + + it('Fallback 路径: im.message.patch 发完整渲染卡', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 99991672 }, + 'im.message.create': { data: { message_id: 'om_fb' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + sc.appendText('## Heading\n\nContent') + await sleep(1600) // 等 PATCH_MS 窗口 + await sc.finalize() + + const patchCalls = calls.filter((c) => c.api === 'im.message.patch') + expect(patchCalls.length).toBeGreaterThan(0) + // 最后一次 patch 是 finalize 的(full final card) + const lastPatch = patchCalls[patchCalls.length - 1]! + const finalCard = JSON.parse(lastPatch.args.data.content) + const finalContent = finalCard.body.elements[0].content + // ## → ##### 降级 + expect(finalContent).toContain('##### Heading') + }) + + it('完全 idle 时 finalize 直接标记 completed 不抛错', async () => { + const { client } = makeMockClient() + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.finalize() + expect(sc._getPhase()).toBe('completed') + }) + + it('finalize 失败不抛出', async () => { + const { client } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck' } }, + 'im.message.create': { data: { message_id: 'om' } }, + 'card.settings': () => { + throw new Error('settings exploded') + }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + sc.appendText('text') + await sleep(150) + // finalize 内部捕获错误不 rethrow + await sc.finalize() + expect(sc._getPhase()).toBe('completed') + }) +}) + +// --------------------------------------------------------------------------- +// Rate limit + table limit +// --------------------------------------------------------------------------- + +describe('StreamingCard: 错误处理', () => { + it('230020 rate limit → 跳帧,后续 flush 继续', async () => { + let callIdx = 0 + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck' } }, + 'im.message.create': { data: { message_id: 'om' } }, + 'cardElement.content': () => { + const i = callIdx++ + if (i === 0) { + const err: any = new Error('rate limit') + err.code = 230020 + throw err + } + return { code: 0 } + }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + sc.appendText('first') + await sleep(150) + // 第一次被限流 + sc.appendText(' second') + await sleep(150) + // 第二次应能成功 + + // CardKit 仍然 active(没降级) + expect(sc._isCardKitStreamActive()).toBe(true) + const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content') + expect(contentCalls.length).toBeGreaterThanOrEqual(2) + }) + + it('230099 table limit → 禁用流式但 cardId 保留,finalize 仍走 CardKit', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_tbl' } }, + 'im.message.create': { data: { message_id: 'om' } }, + 'cardElement.content': () => { + const err: any = new Error('content failed') + err.code = 230099 + err.msg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ' + throw err + }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + sc.appendText('some content') + await sleep(150) + expect(sc._isCardKitStreamActive()).toBe(false) + expect(sc._getCardId()).toBe('ck_tbl') // card_id 保留 + + await sc.finalize() + // finalize 仍然走 CardKit 的 settings + update(cardId 还在) + expect(calls.some((c) => c.api === 'cardkit.v1.card.settings')).toBe(true) + expect(calls.some((c) => c.api === 'cardkit.v1.card.update')).toBe(true) + // 不走 patch + expect(calls.some((c) => c.api === 'im.message.patch')).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// abort +// --------------------------------------------------------------------------- + +describe('StreamingCard: abort', () => { + it('CardKit 路径: 渲染错误卡并关闭流式', async () => { + const { client, calls } = makeMockClient({ + 'card.create': { code: 0, data: { card_id: 'ck_err' } }, + 'im.message.create': { data: { message_id: 'om' } }, + }) + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.ensureCreated() + sc.appendText('partial...') + await sleep(150) + + await sc.abort(new Error('something went wrong')) + expect(sc._getPhase()).toBe('aborted') + + const updateCalls = calls.filter((c) => c.api === 'cardkit.v1.card.update') + expect(updateCalls.length).toBeGreaterThan(0) + const errCard = JSON.parse(updateCalls[updateCalls.length - 1]!.args.data.card.data) + expect(errCard.header.template).toBe('red') + expect(errCard.body.elements[0].content).toContain('something went wrong') + // 保留已累积的部分文本 + expect(errCard.body.elements[0].content).toContain('partial...') + }) + + it('idle 阶段 abort 不抛错', async () => { + const { client } = makeMockClient() + const sc = new StreamingCard({ larkClient: client, chatId: 'c' }) + await sc.abort(new Error('before any card')) + expect(sc._getPhase()).toBe('aborted') + }) +}) diff --git a/adapters/feishu/card-errors.ts b/adapters/feishu/card-errors.ts new file mode 100644 index 00000000..89130a2c --- /dev/null +++ b/adapters/feishu/card-errors.ts @@ -0,0 +1,151 @@ +/** + * Feishu CardKit API 错误码解析与谓词 + * + * 参考实现: openclaw-lark/src/card/card-error.ts + src/core/api-error.ts + * + * Lark SDK 抛出的错误对象结构有多种: + * - SDK 把 Feishu 的 {code, msg} 直接挂在 error 对象上 + * - Axios 风格: error.response.data.{code, msg} + * - data.code 嵌套(某些包装层) + * + * 此模块把这些统一成 { code, subCode, errMsg } 结构, + * 供 streaming-card-controller 判断是否跳帧重试、或降级到 Patch 路径。 + */ + +// --------------------------------------------------------------------------- +// Error code constants +// --------------------------------------------------------------------------- + +/** 卡片 API 级别错误码。 */ +export const CARD_ERROR = { + /** 发送频率限制。需跳过当前帧,下次 flush 继续。 */ + RATE_LIMITED: 230020, + /** 卡片内容创建失败(通用码,需看子错误确认具体原因)。 */ + CARD_CONTENT_FAILED: 230099, +} as const + +/** + * 230099 的子错误码,嵌套在 msg 的 `ErrCode: xxx` 字段中。 + * 11310 是通用的"元素超限"码,需配合 errMsg 匹配具体原因。 + */ +export const CARD_CONTENT_SUB_ERROR = { + /** 卡片元素(表格等)数量超限 */ + ELEMENT_LIMIT: 11310, +} as const + +// --------------------------------------------------------------------------- +// Code extraction +// --------------------------------------------------------------------------- + +function coerceCode(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string') { + const parsed = Number(value) + if (Number.isFinite(parsed)) return parsed + } + return undefined +} + +/** + * 从 Lark SDK 抛错对象中提取飞书 API code。支持三种结构: + * - `{ code }` (SDK 直接挂载) + * - `{ data: { code } }` (响应体嵌套) + * - `{ response: { data: { code } } }` (Axios 风格) + */ +export function extractLarkApiCode(err: unknown): number | undefined { + if (!err || typeof err !== 'object') return undefined + const e = err as { + code?: unknown + data?: { code?: unknown } + response?: { data?: { code?: unknown } } + } + return coerceCode(e.code) ?? coerceCode(e.data?.code) ?? coerceCode(e.response?.data?.code) +} + +// --------------------------------------------------------------------------- +// Sub-error extraction +// --------------------------------------------------------------------------- + +/** + * 从 msg 字符串里提取子错误码(`ErrCode: xxx`)。 + * + * 示例输入: + * "Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ..." + * 返回: 11310 + */ +export function extractSubCode(msg: string): number | null { + const match = /ErrCode:\s*(\d+)/.exec(msg) + if (!match) return null + const code = Number(match[1]) + return Number.isFinite(code) ? code : null +} + +// --------------------------------------------------------------------------- +// Structured error parsing +// --------------------------------------------------------------------------- + +export type CardApiErrorInfo = { + code: number + subCode: number | null + errMsg: string +} + +/** + * 从任意抛错对象中解析卡片 API 错误结构。 + * + * 返回 { code, subCode, errMsg }。无法提取 code 时返回 null。 + */ +export function parseCardApiError(err: unknown): CardApiErrorInfo | null { + const code = extractLarkApiCode(err) + if (code === undefined) return null + + // 按优先级提取 msg 文本 + let errMsg = '' + if (err && typeof err === 'object') { + const e = err as { + msg?: unknown + message?: unknown + response?: { data?: { msg?: unknown } } + } + if (typeof e.msg === 'string') { + errMsg = e.msg + } else if (typeof e.response?.data?.msg === 'string') { + errMsg = e.response.data.msg + } else if (typeof e.message === 'string') { + errMsg = e.message + } + } + + const subCode = extractSubCode(errMsg) + return { code, subCode, errMsg } +} + +// --------------------------------------------------------------------------- +// Helper predicates +// --------------------------------------------------------------------------- + +/** 判断错误是否为卡片发送频率限制(230020)。 */ +export function isCardRateLimitError(err: unknown): boolean { + const parsed = parseCardApiError(err) + if (!parsed) return false + return parsed.code === CARD_ERROR.RATE_LIMITED +} + +/** + * 判断错误是否为卡片表格数超限。 + * + * 匹配条件: code 230099 + subCode 11310 + errMsg 含 "table number over limit" + * (11310 是通用元素超限码,光靠它不够;必须同时检查 errMsg 锁定是表格数量问题)。 + * + * 实际生产错误格式(openclaw-lark 2026-03 实测): + * "Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; " + */ +export function isCardTableLimitError(err: unknown): boolean { + const parsed = parseCardApiError(err) + if (!parsed) return false + return ( + parsed.code === CARD_ERROR.CARD_CONTENT_FAILED && + parsed.subCode === CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT && + /table number over limit/i.test(parsed.errMsg) + ) +} diff --git a/adapters/feishu/cardkit.ts b/adapters/feishu/cardkit.ts new file mode 100644 index 00000000..c331976c --- /dev/null +++ b/adapters/feishu/cardkit.ts @@ -0,0 +1,294 @@ +/** + * 飞书 CardKit API 薄封装 + * + * 这是生产路径的核心:openclaw-lark 的 CardKit 主路径等价实现。 + * + * 五步流程: + * 1. createCardEntity() —— 创建卡片实体,返回 card_id + * 2. sendCardAsMessage() —— 通过 IM 消息把卡片挂到聊天窗,返回 message_id + * 3. streamCardContent() —— 循环调用,按 element_id 增量追加文本 + * 4. setCardStreamingMode() —— 关闭流式模式(收尾前必须做) + * 5. updateCardKitCard() —— 全量替换卡片为最终态 + * + * 关键约束: + * - 每次 3/4/5 类调用必须携带**单调递增**的 sequence,否则飞书拒绝 + * - streamCardContent 传的是**完整累计文本**,不是 delta + * - 必须关闭 streaming_mode 后卡片才能被用户交互 + * + * 参考实现: openclaw-lark/src/card/cardkit.ts + */ + +import type * as Lark from '@larksuiteoapi/node-sdk' + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** 流式 markdown 元素的固定 element_id。卡片 JSON 里用这个 id 标记要被 + * `cardElement.content()` 更新的那一个 markdown 元素。 */ +export const STREAMING_ELEMENT_ID = 'streaming_content' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * SDK 返回的通用响应结构。 + * SDK 的 TypeScript 类型不完整,运行时实际返回 { code, msg, data }。 + * 我们统一当成 CardKitResponse 处理以免到处 `as any`。 + */ +type CardKitResponse = { + code?: number + msg?: string + data?: Record + [key: string]: unknown +} + +/** 非零 code 时抛出的结构化错误。字段与 Lark SDK 的标准错误对齐, + * 可被 card-errors.ts 的 parseCardApiError 识别。 */ +export class CardKitApiError extends Error { + readonly code: number + readonly msg: string + + constructor(params: { api: string; code: number; msg: string; context: string }) { + const { api, code, msg, context } = params + super(`cardkit ${api} FAILED: code=${code}, msg=${msg}, ${context}`) + this.name = 'CardKitApiError' + this.code = code + this.msg = msg + } +} + +type LarkClient = Lark.Client + +// --------------------------------------------------------------------------- +// Response check +// --------------------------------------------------------------------------- + +/** + * 检查 CardKit 响应的 body-level code。非 0 → 抛 CardKitApiError。 + * + * Fail-fast 策略: 让 streaming-card 用 try/catch 配合 card-errors 统一 + * 判断是速率限制还是真错误。 + */ +function assertCardKitOk(params: { + resp: CardKitResponse + api: string + context: string +}): void { + const { resp, api, context } = params + const code = resp.code + if (code !== undefined && code !== 0) { + throw new CardKitApiError({ + api, + code, + msg: typeof resp.msg === 'string' ? resp.msg : '', + context, + }) + } +} + +// --------------------------------------------------------------------------- +// Step 1 — createCardEntity +// --------------------------------------------------------------------------- + +/** + * 创建一张 CardKit 卡片实体,返回 card_id。 + * + * 此时卡片还没挂到任何聊天窗。需要再调 sendCardAsMessage 才能显示。 + * + * @param client Lark SDK client + * @param card Schema 2.0 格式的卡片 JSON + * @returns 飞书分配的 card_id(失败时抛错) + */ +export async function createCardEntity( + client: LarkClient, + card: Record, +): Promise { + // SDK 返回类型不完整,cast 到运行时实际结构 + const resp = (await client.cardkit.v1.card.create({ + data: { + type: 'card_json', + data: JSON.stringify(card), + }, + })) as unknown as CardKitResponse + + assertCardKitOk({ + resp, + api: 'card.create', + context: `cardLen=${JSON.stringify(card).length}`, + }) + + // 兼容不同 SDK 包装层:data.card_id 优先,回退顶层 card_id + const cardId = + (resp.data?.card_id as string | undefined) ?? + (resp.card_id as string | undefined) + + if (!cardId) { + throw new CardKitApiError({ + api: 'card.create', + code: resp.code ?? -1, + msg: 'response missing card_id', + context: `resp=${JSON.stringify(resp).slice(0, 200)}`, + }) + } + return cardId +} + +// --------------------------------------------------------------------------- +// Step 2 — sendCardAsMessage +// --------------------------------------------------------------------------- + +/** + * 把 CardKit 卡片通过 IM 消息挂到聊天窗。 + * + * content 格式: `{"type":"card","data":{"card_id":"xxx"}}` + * msg_type 固定为 `interactive`。 + * + * @param client Lark SDK client + * @param chatId 目标 chat_id + * @param cardId CardKit card_id(由 createCardEntity 产生) + * @param replyToMessageId 可选。如果提供,走 im.message.reply;否则 im.message.create + * @returns 飞书分配的 message_id + */ +export async function sendCardAsMessage( + client: LarkClient, + chatId: string, + cardId: string, + replyToMessageId?: string, +): Promise { + const content = JSON.stringify({ + type: 'card', + data: { card_id: cardId }, + }) + + if (replyToMessageId) { + const resp = await client.im.message.reply({ + path: { message_id: replyToMessageId }, + data: { content, msg_type: 'interactive' }, + }) + const messageId = resp.data?.message_id + if (!messageId) { + throw new CardKitApiError({ + api: 'im.message.reply', + code: -1, + msg: 'response missing message_id', + context: `cardId=${cardId}`, + }) + } + return messageId + } + + const resp = await client.im.message.create({ + params: { receive_id_type: 'chat_id' }, + data: { + receive_id: chatId, + msg_type: 'interactive', + content, + }, + }) + const messageId = resp.data?.message_id + if (!messageId) { + throw new CardKitApiError({ + api: 'im.message.create', + code: -1, + msg: 'response missing message_id', + context: `chatId=${chatId} cardId=${cardId}`, + }) + } + return messageId +} + +// --------------------------------------------------------------------------- +// Step 3 — streamCardContent +// --------------------------------------------------------------------------- + +/** + * 流式更新指定 element 的内容。飞书自动对比旧内容做 diff,在客户端 + * 渲染打字机效果。 + * + * **重要**: `content` 必须传**完整累计文本**,不是 delta。 + * sequence 必须**单调递增**,否则飞书拒绝。 + * + * @param client Lark SDK client + * @param cardId CardKit card_id + * @param elementId 要更新的元素 id(通常是 STREAMING_ELEMENT_ID) + * @param content 完整累计文本 + * @param sequence 单调递增序列号 + */ +export async function streamCardContent( + client: LarkClient, + cardId: string, + elementId: string, + content: string, + sequence: number, +): Promise { + const resp = (await client.cardkit.v1.cardElement.content({ + data: { content, sequence }, + path: { card_id: cardId, element_id: elementId }, + })) as unknown as CardKitResponse + + assertCardKitOk({ + resp, + api: 'cardElement.content', + context: `seq=${sequence} len=${content.length}`, + }) +} + +// --------------------------------------------------------------------------- +// Step 4 — setCardStreamingMode +// --------------------------------------------------------------------------- + +/** + * 开/关卡片的流式模式。收尾前必须调用 `streamingMode: false`, + * 否则卡片会保持"只读"状态,用户点按钮没反应。 + */ +export async function setCardStreamingMode( + client: LarkClient, + cardId: string, + streamingMode: boolean, + sequence: number, +): Promise { + const resp = (await client.cardkit.v1.card.settings({ + data: { + settings: JSON.stringify({ streaming_mode: streamingMode }), + sequence, + }, + path: { card_id: cardId }, + })) as unknown as CardKitResponse + + assertCardKitOk({ + resp, + api: 'card.settings', + context: `seq=${sequence} streaming_mode=${streamingMode}`, + }) +} + +// --------------------------------------------------------------------------- +// Step 5 — updateCardKitCard +// --------------------------------------------------------------------------- + +/** + * 全量替换卡片为新的 JSON。用于流式结束后把卡片切换成最终态 + * (加 header template、footer、完成样式等)。 + */ +export async function updateCardKitCard( + client: LarkClient, + cardId: string, + card: Record, + sequence: number, +): Promise { + const resp = (await client.cardkit.v1.card.update({ + data: { + card: { type: 'card_json', data: JSON.stringify(card) }, + sequence, + }, + path: { card_id: cardId }, + })) as unknown as CardKitResponse + + assertCardKitOk({ + resp, + api: 'card.update', + context: `seq=${sequence} cardId=${cardId}`, + }) +} diff --git a/adapters/feishu/flush-controller.ts b/adapters/feishu/flush-controller.ts new file mode 100644 index 00000000..4f16dd5e --- /dev/null +++ b/adapters/feishu/flush-controller.ts @@ -0,0 +1,149 @@ +/** + * 节流 + 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 | null = null + private lastUpdateTime = 0 + private isCompleted = false + private _cardMessageReady = false + + constructor(private readonly doFlush: () => Promise) {} + + /** 标记完成 —— 当前 flush 跑完后不再接受新的。 */ + complete(): void { + this.isCompleted = true + } + + /** 取消任何挂起的延迟 flush 计时器。 */ + cancelPendingFlush(): void { + if (this.pendingFlushTimer) { + clearTimeout(this.pendingFlushTimer) + this.pendingFlushTimer = null + } + } + + /** 等待当前正在跑的 flush 结束。没在跑则立即返回。 */ + waitForFlush(): Promise { + if (!this.flushInProgress) return Promise.resolve() + return new Promise((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 { + 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 { + 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) + } + } +} diff --git a/adapters/feishu/index.ts b/adapters/feishu/index.ts index 3b0ec41a..46c406df 100644 --- a/adapters/feishu/index.ts +++ b/adapters/feishu/index.ts @@ -10,8 +10,8 @@ import * as Lark from '@larksuiteoapi/node-sdk' import * as path from 'node:path' import { WsBridge, type ServerMessage } from '../common/ws-bridge.js' -import { MessageBuffer } from '../common/message-buffer.js' import { MessageDedup } from '../common/message-dedup.js' +import { StreamingCard } from './streaming-card.js' import { enqueue } from '../common/chat-queue.js' import { loadConfig } from '../common/config.js' import { @@ -44,15 +44,8 @@ const dedup = new MessageDedup() const sessionStore = new SessionStore() const httpClient = new AdapterHttpClient(config.serverUrl) -// Track state per chat -type ChatState = { - cardId?: string - sequence: number - replyMessageId?: string -} -const chatStates = new Map() -const buffers = new Map() -const accumulatedText = new Map() +// One streaming card lifecycle per chatId (CardKit main + patch fallback). +const streamingCards = new Map() const pendingProjectSelection = new Map() const runtimeStates = new Map() @@ -70,26 +63,6 @@ type ChatRuntimeState = { // ---------- helpers ---------- -function getChatState(chatId: string): ChatState { - let state = chatStates.get(chatId) - if (!state) { - state = { sequence: 0 } - chatStates.set(chatId, state) - } - return state -} - -function getBuffer(chatId: string): MessageBuffer { - let buf = buffers.get(chatId) - if (!buf) { - buf = new MessageBuffer(async (text, isComplete) => { - await flushToFeishu(chatId, text, isComplete) - }) - buffers.set(chatId, buf) - } - return buf -} - function getRuntimeState(chatId: string): ChatRuntimeState { let state = runtimeStates.get(chatId) if (!state) { @@ -99,10 +72,39 @@ function getRuntimeState(chatId: string): ChatRuntimeState { return state } +/** Get the existing StreamingCard for this chat, or create one in 'idle' state. */ +function getOrCreateStreamingCard(chatId: string): StreamingCard { + let card = streamingCards.get(chatId) + if (!card) { + card = new StreamingCard({ larkClient, chatId }) + streamingCards.set(chatId, card) + } + return card +} + +/** Finalize and remove the streaming card (normal completion). */ +async function finalizeStreamingCard(chatId: string): Promise { + const card = streamingCards.get(chatId) + if (!card) return + streamingCards.delete(chatId) + await card.finalize() +} + +/** Abort and remove the streaming card (error path). Non-throwing. */ +async function abortStreamingCard(chatId: string, err: Error): Promise { + const card = streamingCards.get(chatId) + if (!card) return + streamingCards.delete(chatId) + await card.abort(err).catch(() => {}) +} + function clearTransientChatState(chatId: string): void { - chatStates.delete(chatId) - accumulatedText.delete(chatId) - buffers.get(chatId)?.reset() + // Abort any in-flight streaming card (best effort, don't block) + const card = streamingCards.get(chatId) + if (card) { + streamingCards.delete(chatId) + void card.abort(new Error('session cleared')).catch(() => {}) + } const runtime = getRuntimeState(chatId) runtime.state = 'idle' runtime.verb = undefined @@ -221,43 +223,6 @@ async function sendCard(chatId: string, card: Record): Promise< } } -/** Build a simple streaming text card (Schema 1.0, patchable via im.message.patch). - * - * The `content` is run through `optimizeMarkdownForFeishu` first: Feishu's - * `tag:'markdown'` element has a known rendering bug where H1~H3 headings - * (`#`, `##`, `###`) show up as literal text instead of being styled. - * openclaw-lark works around this in `src/card/markdown-style.ts` by - * downgrading H1→H4 and H2~H6→H5. We do the same here. */ -function buildStreamingCard(text: string): Record { - const content = optimizeMarkdownForFeishu(text || ' ') - return { - config: { - wide_screen_mode: true, - update_multi: true, - }, - elements: [ - { tag: 'markdown', content }, - ], - } -} - -/** Update a streaming card message's content (patch). - * Note: im.message.patch only works on interactive cards, so the source - * message MUST have been sent via sendCard(buildStreamingCard(...)). */ -async function patchMessage(messageId: string, text: string): Promise { - try { - await larkClient.im.message.patch({ - path: { message_id: messageId }, - data: { - content: JSON.stringify(buildStreamingCard(text)), - }, - }) - } catch (err) { - // patch may fail (rate limit, message expired, etc.) — log and ignore - console.error('[Feishu] patchMessage error:', err instanceof Error ? err.message : err) - } -} - /** Pretty-print an absolute path for IM display. * - Replace $HOME with `~` * - Middle-truncate if it's still very long, keeping the project tail visible */ @@ -582,36 +547,6 @@ function buildPermissionCard( } } -async function flushToFeishu(chatId: string, newText: string, isComplete: boolean): Promise { - const prev = accumulatedText.get(chatId) ?? '' - const fullText = prev + newText - accumulatedText.set(chatId, fullText) - - const state = getChatState(chatId) - - if (state.replyMessageId) { - const displayText = fullText + (isComplete ? '' : ' ▍') - await patchMessage(state.replyMessageId, displayText) - } - - if (isComplete) { - if (!state.replyMessageId && fullText.trim()) { - // Fallback path: no streaming card was created for this text block - // (e.g. sendCard failed, or upstream never fired content_start{text}). - // Must go through sendCard(buildStreamingCard), NOT sendText — the - // latter uses msg_type=post + tag:'md' which is INLINE-ONLY and won't - // render block-level markdown (#, ```fenced```, tables, lists). - const chunks = splitMessage(fullText, 30000) - for (const chunk of chunks) { - await sendCard(chatId, buildStreamingCard(chunk)) - } - } - accumulatedText.delete(chatId) - chatStates.delete(chatId) - buffers.get(chatId)?.reset() - } -} - // ---------- session management ---------- async function ensureSession(chatId: string): Promise { @@ -635,6 +570,18 @@ async function ensureSession(chatId: string): Promise { async function createSessionForChat(chatId: string, workDir: string): Promise { try { + // Always tear down any stale WS connection before creating a new session. + // Without this, bridge.connectSession() below would short-circuit when an + // old OPEN connection still exists (e.g. /projects → pick_project path), + // leaving user messages routed to the previous session's workDir. + bridge.resetSession(chatId) + // Also abort any in-flight streaming card tied to the old session. + const inflightCard = streamingCards.get(chatId) + if (inflightCard) { + streamingCards.delete(chatId) + void inflightCard.abort(new Error('session reset')).catch(() => {}) + } + const sessionId = await httpClient.createSession(workDir) sessionStore.set(chatId, sessionId, workDir) bridge.connectSession(chatId, sessionId) @@ -676,10 +623,12 @@ async function showProjectPicker(chatId: string): Promise { async function startNewSession(chatId: string, query?: string): Promise { bridge.resetSession(chatId) sessionStore.delete(chatId) - chatStates.delete(chatId) - accumulatedText.delete(chatId) - buffers.get(chatId)?.reset() - buffers.delete(chatId) + // Abort any in-flight streaming card for the previous session + const inflightCard = streamingCards.get(chatId) + if (inflightCard) { + streamingCards.delete(chatId) + void inflightCard.abort(new Error('session reset')).catch(() => {}) + } pendingProjectSelection.delete(chatId) runtimeStates.delete(chatId) @@ -719,62 +668,52 @@ async function startNewSession(chatId: string, query?: string): Promise { // ---------- server message handler ---------- async function handleServerMessage(chatId: string, msg: ServerMessage): Promise { - const buf = getBuffer(chatId) - const state = getChatState(chatId) const runtime = getRuntimeState(chatId) switch (msg.type) { case 'connected': break - case 'status': + case 'status': { runtime.state = msg.state runtime.verb = typeof msg.verb === 'string' ? msg.verb : undefined - if (msg.state === 'thinking' && !state.replyMessageId) { - const mid = await sendCard(chatId, buildStreamingCard('💭 思考中...')) - if (mid) { - state.replyMessageId = mid - accumulatedText.set(chatId, '') - } - } + // 注意: 故意不在 thinking 时创建卡片。/clear、/compact 这类命令 + // 不产生文本输出,但 CLI 仍会发 thinking → message_complete 事件。 + // 如果在 thinking 就建卡,这些命令会留下一张空卡片。 + // 真正的创建时机是 content_start{text} 或第一次 content_delta。 break + } - case 'content_start': + case 'content_start': { if (msg.blockType === 'text') { - if (!state.replyMessageId) { - const mid = await sendCard(chatId, buildStreamingCard('▍')) - if (mid) { - state.replyMessageId = mid - accumulatedText.set(chatId, '') - } - } - } else if (msg.blockType === 'tool_use') { - // Finalize current text before tool calls, - // so text after tools gets a fresh message - await buf.complete() - // If reply still exists (buffer was already empty), clean up directly - if (state.replyMessageId) { - const text = accumulatedText.get(chatId) - if (text?.trim()) { - await patchMessage(state.replyMessageId, text) - } - accumulatedText.delete(chatId) - chatStates.delete(chatId) - buffers.get(chatId)?.reset() - } + // 幂等: 预建卡或上一次 content_delta 已经创建了卡片则复用,否则现在创建 + const card = getOrCreateStreamingCard(chatId) + await card.ensureCreated().catch((err) => { + console.error('[Feishu] ensureCreated on content_start failed:', err) + }) } + // 注意: tool_use 不 finalize 当前卡。让整个 turn 的所有文本输出 + // 合并到同一张卡里 —— 更接近 Desktop UI 的一体化答复体验,也避免 + // "预建空卡 + tool_use finalize → 留下空白卡" 的视觉 bug。 break + } - case 'content_delta': - if (msg.text) { - buf.append(msg.text) + case 'content_delta': { + if (typeof msg.text === 'string' && msg.text) { + // 正常情况 content_start{text} 已经创建了卡片,这里直接 appendText。 + // 极端情况(上游跳过了 content_start)也要能容错 —— getOrCreate + async ensureCreated。 + const card = getOrCreateStreamingCard(chatId) + // ensureCreated 幂等,已 streaming 时是 no-op + void card.ensureCreated().catch((err) => { + console.error('[Feishu] ensureCreated on delta failed:', err) + }) + card.appendText(msg.text) } break + } case 'thinking': - if (state.replyMessageId) { - await patchMessage(state.replyMessageId, `💭 ${msg.text.slice(0, 500)}...`) - } + // 推理文本(reasoning),当前版本不单独渲染,等以后加 collapsible panel break case 'tool_use_complete': @@ -783,7 +722,6 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< case 'tool_result': // Tool errors are handled internally by the AI (retries etc.) - // No need to notify the user for every failed attempt. break case 'permission_request': { @@ -803,23 +741,18 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< case 'message_complete': runtime.state = 'idle' runtime.verb = undefined - await buf.complete() - // Ensure state is always cleaned up even if buffer was already empty - if (state.replyMessageId) { - const text = accumulatedText.get(chatId) - if (text?.trim()) { - await patchMessage(state.replyMessageId, text) - } - accumulatedText.delete(chatId) - chatStates.delete(chatId) - buffers.get(chatId)?.reset() - } + await finalizeStreamingCard(chatId) break case 'error': runtime.state = 'idle' runtime.verb = undefined - await sendText(chatId, `❌ ${msg.message}`) + // 如果 streaming card 存在就把错误渲染到卡上,否则 fallback 到 sendText + if (streamingCards.has(chatId)) { + await abortStreamingCard(chatId, new Error(msg.message ?? 'unknown error')) + } else { + await sendText(chatId, `❌ ${msg.message}`) + } break case 'system_notification': @@ -917,61 +850,79 @@ async function handleMessage(data: any): Promise { text = stripMentions(text) if (!text) return - // Handle commands - if (text === '/new' || text === '新会话' || text.startsWith('/new ')) { - const arg = text.startsWith('/new ') ? text.slice(5).trim() : '' - await startNewSession(chatId, arg || undefined) - return - } - if (text === '/help' || text === '帮助') { - await sendText(chatId, formatImHelp()) - return - } - if (text === '/status' || text === '状态') { - await sendText(chatId, await buildStatusText(chatId)) - return - } - if (text === '/clear' || text === '清空') { - const stored = await ensureExistingSession(chatId) - if (!stored) { - await sendText(chatId, formatImStatus(null)) - return - } - clearTransientChatState(chatId) - const sent = bridge.sendUserMessage(chatId, '/clear') - if (!sent) { - await sendText(chatId, '⚠️ 无法发送 /clear,请先发送 /new 重新连接会话。') - return - } - await sendText(chatId, '🧹 已清空当前会话上下文。') - return - } - if (text === '/stop' || text === '停止') { - const stored = await ensureExistingSession(chatId) - if (!stored) { - await sendText(chatId, formatImStatus(null)) - return - } - bridge.sendStopGeneration(chatId) - await sendText(chatId, '⏹ 已发送停止信号。') - return - } - if (text === '/projects' || text === '项目列表') { - await showProjectPicker(chatId) - return - } + const msgText = text // capture in a const so TypeScript knows it's not null inside closures - // Check if user is responding to project selection - if (pendingProjectSelection.has(chatId)) { - await startNewSession(chatId, text.trim()) - return - } - - // Normal message flow + // All user input (commands + normal chat) goes through a single per-chat + // serial queue. Without this, rapidly-fired commands could have their + // async bodies interleave at `await` points, causing reply messages + // (e.g. "🧹 已清空..." after "✅ 已新建...") to appear in the wrong order. enqueue(chatId, async () => { + // ----- Commands ----- + + if (msgText === '/new' || msgText === '新会话' || msgText.startsWith('/new ')) { + const arg = msgText.startsWith('/new ') ? msgText.slice(5).trim() : '' + await startNewSession(chatId, arg || undefined) + return + } + if (msgText === '/help' || msgText === '帮助') { + await sendText(chatId, formatImHelp()) + return + } + if (msgText === '/status' || msgText === '状态') { + await sendText(chatId, await buildStatusText(chatId)) + return + } + if (msgText === '/clear' || msgText === '清空') { + const stored = await ensureExistingSession(chatId) + if (!stored) { + await sendText(chatId, formatImStatus(null)) + return + } + clearTransientChatState(chatId) + const sent = bridge.sendUserMessage(chatId, '/clear') + if (!sent) { + await sendText(chatId, '⚠️ 无法发送 /clear,请先发送 /new 重新连接会话。') + return + } + await sendText(chatId, '🧹 已清空当前会话上下文。') + return + } + if (msgText === '/stop' || msgText === '停止') { + const stored = await ensureExistingSession(chatId) + if (!stored) { + await sendText(chatId, formatImStatus(null)) + return + } + bridge.sendStopGeneration(chatId) + await sendText(chatId, '⏹ 已发送停止信号。') + return + } + if (msgText === '/projects' || msgText === '项目列表') { + await showProjectPicker(chatId) + return + } + + // User is replying to a project picker prompt + if (pendingProjectSelection.has(chatId)) { + await startNewSession(chatId, msgText.trim()) + return + } + + // ----- Normal message flow ----- + const ready = await ensureSession(chatId) if (ready) { - const sent = bridge.sendUserMessage(chatId, text!) + // Pre-create the streaming card immediately so the user sees a + // "☁️ 正在思考中..." indicator while the backend is still thinking + // (before the first content_delta arrives). We intentionally do NOT + // create a card for /clear-style commands (which go through the + // earlier branches), so they won't leave an empty card behind. + const card = getOrCreateStreamingCard(chatId) + void card.ensureCreated().catch((err) => { + console.error('[Feishu] pre-create streaming card failed:', err) + }) + + const sent = bridge.sendUserMessage(chatId, msgText) if (!sent) { await sendText(chatId, '⚠️ 消息发送失败,连接可能已断开。请发送 /new 重新开始。') } diff --git a/adapters/feishu/markdown-style.ts b/adapters/feishu/markdown-style.ts index 0b50aac8..e75425e9 100644 --- a/adapters/feishu/markdown-style.ts +++ b/adapters/feishu/markdown-style.ts @@ -1,29 +1,47 @@ /** * Feishu 卡片 Markdown 样式优化 * - * 背景: 飞书卡片的 `tag: 'markdown'` 元素对 H1~H3 标题有已知渲染异常 - * (字面量显示 `#`/`##`/`###`)。必须降级为 H4/H5 才能正常渲染。 + * 背景: + * - 飞书卡片的 `tag: 'markdown'` 元素对 H1~H3 标题有已知渲染异常(字面量显示)。 + * 必须降级为 H4/H5 才能正常渲染。 + * - Schema 2.0 CardKit 支持完整的 markdown 但需要手动加 `
` 间距避免标题/表格/ + * 代码块贴得太紧。 + * - 卡片有表格数量上限(FEISHU_CARD_TABLE_LIMIT=3),超出会触发 230099/11310。 + * - 图片必须是飞书上传过的 `img_xxx` key,其它 URL 会触发 CardKit 错误 200570。 * - * 实现参考: openclaw-lark/src/card/markdown-style.ts - * - * 本文件只做「保证能渲染」的最小预处理,不加 Schema 2.0 专属的 - *
间距美化(cardVersion 参数预留以便将来升级)。 + * 实现参考: openclaw-lark/src/card/markdown-style.ts + card-error.ts */ +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** 飞书卡片表格数量上限 —— 超出 3 张触发 230099/11310(openclaw 2026-03 实测) */ +export const FEISHU_CARD_TABLE_LIMIT = 3 + +// --------------------------------------------------------------------------- +// Public: optimizeMarkdownForFeishu +// --------------------------------------------------------------------------- + /** - * 对将要放入 `tag: 'markdown'` 元素的内容做安全预处理。 + * 对将要放入 `tag: 'markdown'` 元素的 markdown 做安全预处理。 * * - 标题降级: 若原文包含 H1~H3,则 H2~H6 → H5,H1 → H4 * - 代码块内容受保护,不会被降级 + * - Schema 2.0: 连续标题/表格/代码块前后加 `
` 间距 * - 连续 3+ 空行压缩为 2 + * - 删除非 `img_*` 的 markdown 图片引用(防止 CardKit 200570) * - 任何内部错误都 fallback 到原文,不阻塞消息发送 * * @param text 原始 markdown - * @param cardVersion 卡片 schema 版本,当前只影响是否加段落间距
+ * @param cardVersion 卡片 schema 版本。默认 2 (Schema 2.0 CardKit), + * 1 对应老 Schema 1.0 fallback 路径(已不推荐)。 */ -export function optimizeMarkdownForFeishu(text: string, cardVersion = 1): string { +export function optimizeMarkdownForFeishu(text: string, cardVersion = 2): string { try { - return _optimizeMarkdownForFeishu(text, cardVersion) + let r = _optimizeMarkdownForFeishu(text, cardVersion) + r = stripInvalidImageKeys(r) + return r } catch { return text } @@ -31,7 +49,7 @@ export function optimizeMarkdownForFeishu(text: string, cardVersion = 1): string function _optimizeMarkdownForFeishu(text: string, cardVersion: number): string { // ── 1. 提取代码块,用占位符保护,处理后再还原 ───────────────────── - // 这样代码块内部的 `#` 不会被标题降级误伤 + // 代码块内的 `#` / `|` 不能被标题降级或表格匹配误伤 const MARK = '___CB_' const codeBlocks: string[] = [] let r = text.replace(/```[\s\S]*?```/g, (m) => { @@ -41,31 +59,154 @@ function _optimizeMarkdownForFeishu(text: string, cardVersion: number): string { // ── 2. 标题降级 ──────────────────────────────────────────────────── // 只有当原文档(不是保护后的 r)包含 H1~H3 时才执行降级 // 顺序: 先 H2~H6 → H5,再 H1 → H4 - // 若先 H1→H4,####会被后面的 #{2,6} 再次匹配成 H5(变 ##### Title 两次) + // 若先 H1→H4,`####` 会被后面的 `#{2,6}` 再次匹配成 H5 const hasH1toH3 = /^#{1,3} /m.test(text) if (hasH1toH3) { r = r.replace(/^#{2,6} (.+)$/gm, '##### $1') // H2~H6 → H5 r = r.replace(/^# (.+)$/gm, '#### $1') // H1 → H4 } - // ── 3. Schema 2.0 下可额外加段落间距 ──────────────────────────────── - // 当前飞书 adapter 走 Schema 1.0 / buildStreamingCard,这一块不启用 + // ── 3. Schema 2.0 段落间距 ──────────────────────────────────────── if (cardVersion >= 2) { - // 连续标题之间补
+ // 3a. 连续标题之间加
r = r.replace(/^(#{4,5} .+)\n{1,2}(#{4,5} )/gm, '$1\n
\n$2') + + // 3b. 非表格行直接跟表格行 → 先补空行(保证后续规则生效) + r = r.replace(/^([^|\n].*)\n(\|.+\|)/gm, '$1\n\n$2') + + // 3c. 表格前: 在空行之前插入
(即 `\n\n|` → `\n
\n\n|`) + r = r.replace(/\n\n((?:\|.+\|[^\S\n]*\n?)+)/g, '\n\n
\n\n$1') + + // 3d. 表格后: 在表格块末尾追加
(跳过后接分隔线/标题/加粗/文末) + r = r.replace(/((?:^\|.+\|[^\S\n]*\n?)+)/gm, (m, _table, offset) => { + const after = r.slice(offset + m.length).replace(/^\n+/, '') + if (!after || /^(---|#{4,5} |\*\*)/.test(after)) return m + return m + '\n
\n' + }) + + // 3e. 表格前是普通文本: 只保留
,去掉多余空行 + // "text\n\n
\n\n|" → "text\n
\n|" + r = r.replace(/^((?!#{4,5} )(?!\*\*).+)\n\n(
)\n\n(\|)/gm, '$1\n$2\n$3') + + // 3f. 表格前是加粗行:
紧贴加粗行,空行保留在后面 + // "**bold**\n\n
\n\n|" → "**bold**\n
\n\n|" + r = r.replace(/^(\*\*.+)\n\n(
)\n\n(\|)/gm, '$1\n$2\n\n$3') + + // 3g. 表格后是普通文本: 去掉多余空行 + // "| row |\n\n
\ntext" → "| row |\n
\ntext" + r = r.replace(/(\|[^\n]*\n)\n(
\n)((?!#{4,5} )(?!\*\*))/gm, '$1$2$3') } // ── 4. 压缩多余空行(3 个以上连续换行 → 2 个)──────────────────── - // 注意: 必须在还原代码块之前做,否则代码块内部的连续换行会被误伤。 - // 此时代码块被占位符 `___CB_N___`(单行 token)替代,压缩只影响 - // 真正的 markdown 段落间距。 + // 必须在还原代码块之前做,否则代码块内部的连续换行会被误伤 r = r.replace(/\n{3,}/g, '\n\n') // ── 5. 还原代码块 ───────────────────────────────────────────────── - // Schema 1.0 路径不在代码块前后加
(维持现状,最小变更) + // Schema 2.0 时前后加
,让代码块与周围段落拉开距离 codeBlocks.forEach((block, i) => { - r = r.replace(`${MARK}${i}___`, block) + const replacement = cardVersion >= 2 ? `\n
\n${block}\n
\n` : block + r = r.replace(`${MARK}${i}___`, replacement) }) return r } + +// --------------------------------------------------------------------------- +// stripInvalidImageKeys +// --------------------------------------------------------------------------- + +/** 匹配完整的 markdown 图片语法: `![alt](value)` */ +const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g + +/** + * 删除 value 不是飞书 image key (`img_xxx`) 的 markdown 图片引用。 + * 防止 CardKit 错误 200570(unknown image key)。 + * + * HTTP URL 和本地路径也会被删除 —— 上游(ImageResolver)负责把它们转成 + * `img_xxx`,此函数是 safety net。 + */ +function stripInvalidImageKeys(text: string): string { + if (!text.includes('![')) return text + return text.replace(IMAGE_RE, (fullMatch, _alt, value) => { + if (value.startsWith('img_')) return fullMatch + return '' + }) +} + +// --------------------------------------------------------------------------- +// Table limiting: sanitizeTextForCard +// --------------------------------------------------------------------------- + +export type MarkdownTableMatch = { + index: number + length: number + raw: string +} + +/** + * 扫描正文里会被飞书卡片**实际渲染**的 markdown 表格位置。 + * + * 代码块内的示例表格不会被飞书解析成卡片表格元素,因此要先排除。 + * 这份结果供 `sanitizeTextForCard` 判断是否需要降级多余的表格。 + */ +export function findMarkdownTablesOutsideCodeBlocks(text: string): MarkdownTableMatch[] { + // 先扫描代码块区间 + const codeBlockRanges: Array<{ start: number; end: number }> = [] + const codeBlockRegex = /```[\s\S]*?```/g + let cbMatch = codeBlockRegex.exec(text) + while (cbMatch != null) { + codeBlockRanges.push({ + start: cbMatch.index, + end: cbMatch.index + cbMatch[0].length, + }) + cbMatch = codeBlockRegex.exec(text) + } + const isInsideCodeBlock = (idx: number): boolean => + codeBlockRanges.some((range) => idx >= range.start && idx < range.end) + + // 扫描表格(header | sep | body...) + const tableRegex = /\|.+\|[\r\n]+\|[-:| ]+\|[\s\S]*?(?=\n\n|\n(?!\|)|$)/g + const matches: MarkdownTableMatch[] = [] + let tableMatch = tableRegex.exec(text) + while (tableMatch != null) { + if (!isInsideCodeBlock(tableMatch.index)) { + matches.push({ + index: tableMatch.index, + length: tableMatch[0].length, + raw: tableMatch[0], + }) + } + tableMatch = tableRegex.exec(text) + } + return matches +} + +/** + * 对正文里超出 `tableLimit` 张的 markdown 表格降级为代码块,防止触发 + * 230099/11310。前 `tableLimit` 张保持原样(卡片正常渲染),其余用 + * 反引号包裹(飞书会当成 code block 而不是表格)。 + */ +export function sanitizeTextForCard( + text: string, + tableLimit: number = FEISHU_CARD_TABLE_LIMIT, +): string { + const matches = findMarkdownTablesOutsideCodeBlocks(text) + if (matches.length <= tableLimit) return text + return wrapTablesBeyondLimit(text, matches, Math.max(tableLimit, 0)) +} + +function wrapTablesBeyondLimit( + text: string, + matches: readonly MarkdownTableMatch[], + keepCount: number, +): string { + if (matches.length <= keepCount) return text + // 从后往前替换,避免前面的替换导致后面的 offset 错乱 + let result = text + for (let i = matches.length - 1; i >= keepCount; i--) { + const { index, length, raw } = matches[i]! + const replacement = '```\n' + raw + '\n```' + result = result.slice(0, index) + replacement + result.slice(index + length) + } + return result +} diff --git a/adapters/feishu/streaming-card.ts b/adapters/feishu/streaming-card.ts new file mode 100644 index 00000000..bb411aa7 --- /dev/null +++ b/adapters/feishu/streaming-card.ts @@ -0,0 +1,459 @@ +/** + * 单个 chat 的流式卡片生命周期状态机 + * + * 负责把 LLM 的流式文本增量渲染成一张随着内容生长的飞书 CardKit 卡片。 + * 封装了: + * - CardKit API 的 5 步调用(create → send → stream × N → settings → update) + * - 节流 + 并发保护(FlushController) + * - Markdown 预处理(optimizeMarkdownForFeishu + sanitizeTextForCard) + * - 错误降级:CardKit 挂了自动切到 im.message.patch + Schema 2.0 卡 + * - 速率限制:230020 跳帧,下次重试;230099 表格超限禁用 CardKit 流式 + * + * 每个 chatId 一个实例,由 index.ts 的 handleServerMessage 协调 lifecycle。 + */ + +import type * as Lark from '@larksuiteoapi/node-sdk' +import { FlushController, THROTTLE } from './flush-controller.js' +import { + createCardEntity, + sendCardAsMessage, + streamCardContent, + setCardStreamingMode, + updateCardKitCard, + STREAMING_ELEMENT_ID, +} from './cardkit.js' +import { isCardRateLimitError, isCardTableLimitError } from './card-errors.js' +import { optimizeMarkdownForFeishu, sanitizeTextForCard } from './markdown-style.js' + +// --------------------------------------------------------------------------- +// Card JSON builders +// --------------------------------------------------------------------------- + +/** 初始流式卡片:Schema 2.0 + streaming_mode + element_id。 + * + * 包含两个 markdown 元素: + * - 独立的 loading 提示: "☁️ 正在思考中..." 小号灰字。放在**首位**紧贴 + * 卡片顶部 —— 否则空的 streaming_content 会占默认行高把 loading 挤到 + * 卡片中间,视觉上像大片 padding。 + * - `streaming_content`: 流式内容目标元素,初始为空。由 cardElement.content() + * 逐步填充。位于 loading 下方。 + * + * finalize 时整卡 update 替换,loading 元素自然消失。 + * + * openclaw-lark 用的是 `custom_icon` + 私有 img_key 做 loading 动画, + * 我们没有那个 img_key,用 emoji + notation 文字达到相似的"仍在处理"提示。 */ +export function buildInitialStreamingCard(): Record { + return { + schema: '2.0', + config: { + streaming_mode: true, + update_multi: true, + }, + body: { + elements: [ + { + tag: 'markdown', + content: '☁️ *正在思考中...*', + text_size: 'notation', + }, + { + tag: 'markdown', + content: '', + text_align: 'left', + element_id: STREAMING_ELEMENT_ID, + }, + ], + }, + } +} + +/** 已渲染完成的卡片:Schema 2.0,无 streaming_mode,单 markdown 元素。 + * + * 代码块的 "10 行代码 >" 手机端 bug 是通过 `optimizeMarkdownForFeishu` 把 + * fenced code 降级成纯文字来规避的,不依赖多元素结构。这里就是最朴素的 + * 一张纯 markdown 卡片。 */ +export function buildRenderedCard(renderedMarkdown: string): Record { + return { + schema: '2.0', + config: { + update_multi: true, + }, + body: { + elements: [ + { + tag: 'markdown', + content: renderedMarkdown || ' ', + text_align: 'left', + }, + ], + }, + } +} + +/** 错误卡片:红色 header + 错误文本。用于 abort() 兜底。 */ +export function buildErrorCard(message: string): Record { + return { + schema: '2.0', + config: { update_multi: true }, + header: { + title: { tag: 'plain_text', content: '❌ 出错了' }, + template: 'red', + }, + body: { + elements: [ + { + tag: 'markdown', + content: message || '未知错误', + }, + ], + }, + } +} + +// --------------------------------------------------------------------------- +// State machine +// --------------------------------------------------------------------------- + +export type StreamingCardPhase = + | 'idle' // constructor 后、ensureCreated 之前 + | 'creating' // ensureCreated 进行中 + | 'streaming' // 初始卡已发出,接受 appendText + | 'finalizing' // finalize 进行中 + | 'completed' // finalize 完成 + | 'aborted' // abort 已调用 + +export type StreamingCardDeps = { + larkClient: Lark.Client + chatId: string + replyToMessageId?: string +} + +export class StreamingCard { + // ---- lifecycle state ---- + private phase: StreamingCardPhase = 'idle' + + // ---- CardKit state ---- + /** CardKit card_id。null = CardKit 创建失败,已退到 patch fallback 模式。 */ + private cardId: string | null = null + /** IM message_id。始终应该有值(否则连 patch 也做不了)。 */ + private messageId: string | null = null + /** CardKit cardElement.content() 单调递增序列号。 */ + private sequence = 0 + /** CardKit 流式还在工作。230099 之后置为 false,中间帧将跳过, + * 最终 finalize 仍会尝试 settings+update(cardId 仍然有效)。 */ + private cardKitStreamActive = false + + // ---- text state ---- + private accumulatedText = '' + private lastFlushedText = '' + + // ---- flush ---- + private flushController: FlushController + + constructor(private readonly deps: StreamingCardDeps) { + this.flushController = new FlushController(() => this.performFlush()) + } + + // ------------------------------------------------------------------ + // Public API + // ------------------------------------------------------------------ + + /** + * 首次创建卡片(CardKit 主路径;失败则降级到直发 Schema 2.0 卡 + patch)。 + * 幂等:已创建/正在创建时直接返回。 + */ + async ensureCreated(): Promise { + if (this.phase !== 'idle') return + this.phase = 'creating' + + try { + // CardKit 主路径 + const cardId = await createCardEntity( + this.deps.larkClient, + buildInitialStreamingCard(), + ) + const messageId = await sendCardAsMessage( + this.deps.larkClient, + this.deps.chatId, + cardId, + this.deps.replyToMessageId, + ) + this.cardId = cardId + this.messageId = messageId + this.cardKitStreamActive = true + this.sequence = 1 + this.phase = 'streaming' + this.flushController.setCardMessageReady(true) + } catch (cardKitErr) { + // CardKit 不可用(权限、网络、API 兼容性等)→ 降级到直发卡片 + patch + console.warn( + '[Feishu StreamingCard] CardKit create/send failed, falling back to im.message.patch:', + cardKitErr instanceof Error ? cardKitErr.message : cardKitErr, + ) + try { + const fallbackResp = await this.deps.larkClient.im.message.create({ + params: { receive_id_type: 'chat_id' }, + data: { + receive_id: this.deps.chatId, + msg_type: 'interactive', + content: JSON.stringify(buildRenderedCard(' ')), + }, + }) + const mid = fallbackResp.data?.message_id + if (!mid) { + throw new Error('fallback im.message.create returned no message_id') + } + this.cardId = null + this.messageId = mid + this.cardKitStreamActive = false + this.phase = 'streaming' + this.flushController.setCardMessageReady(true) + } catch (fallbackErr) { + // 兜底都失败了 —— 无法显示任何东西 + console.error( + '[Feishu StreamingCard] Fallback card creation also failed:', + fallbackErr instanceof Error ? fallbackErr.message : fallbackErr, + ) + this.phase = 'aborted' + throw fallbackErr + } + } + + // 卡片可写之后若已有 buffered 文本,立刻触发一次 flush + if (this.accumulatedText.length > 0) { + void this.flushController.throttledUpdate(this.currentThrottle()) + } + } + + /** 追加文本增量。不等待,只安排一次节流 flush。 */ + appendText(delta: string): void { + if (!delta) return + if (this.phase === 'completed' || this.phase === 'aborted') return + this.accumulatedText += delta + void this.flushController.throttledUpdate(this.currentThrottle()) + } + + /** + * 流式结束,切到最终态。 + * - 先 waitForFlush 确保中间帧写入完成 + * - 然后 close streaming_mode(仅 CardKit 路径) + * - 最后用完整 rendered 卡片 update + * - complete FlushController 锁死,后续 appendText 被忽略 + */ + async finalize(): Promise { + if (this.phase === 'completed' || this.phase === 'aborted') return + if (this.phase === 'idle') { + // 完全没开始 —— 直接标记完成 + this.phase = 'completed' + this.flushController.complete() + return + } + this.phase = 'finalizing' + this.flushController.cancelPendingFlush() + await this.flushController.waitForFlush() + + const finalText = this.renderedText() + try { + if (this.cardId) { + // CardKit 路径: settings(false) + card.update(即使中间 stream 曾失败) + this.sequence += 1 + await setCardStreamingMode( + this.deps.larkClient, + this.cardId, + false, + this.sequence, + ) + this.sequence += 1 + await updateCardKitCard( + this.deps.larkClient, + this.cardId, + buildRenderedCard(finalText), + this.sequence, + ) + } else if (this.messageId) { + // Patch fallback 路径: 全量替换 + await this.deps.larkClient.im.message.patch({ + path: { message_id: this.messageId }, + data: { content: JSON.stringify(buildRenderedCard(finalText)) }, + }) + } + } catch (err) { + console.error( + '[Feishu StreamingCard] finalize failed:', + err instanceof Error ? err.message : err, + ) + // 不抛出 —— 用户已经看到某种版本的内容,finalize 失败不是致命错误 + } finally { + this.phase = 'completed' + this.lastFlushedText = finalText + this.flushController.complete() + } + } + + /** 错误中止 —— 尝试把错误信息渲染到卡片上。 */ + async abort(err: Error): Promise { + if (this.phase === 'completed' || this.phase === 'aborted') return + const wasIdle = this.phase === 'idle' + this.phase = 'aborted' + this.flushController.cancelPendingFlush() + await this.flushController.waitForFlush().catch(() => {}) + + if (wasIdle || !this.messageId) { + // 卡片还没创建成功,没法渲染错误 —— 由上层 sendText 兜底 + this.flushController.complete() + return + } + + const errCard = buildErrorCard( + `${err.message}${this.accumulatedText ? '\n\n——\n\n' + this.accumulatedText : ''}`, + ) + try { + if (this.cardId) { + this.sequence += 1 + await setCardStreamingMode( + this.deps.larkClient, + this.cardId, + false, + this.sequence, + ).catch(() => {}) // 关流失败无所谓,update 才是关键 + this.sequence += 1 + await updateCardKitCard( + this.deps.larkClient, + this.cardId, + errCard, + this.sequence, + ) + } else { + await this.deps.larkClient.im.message.patch({ + path: { message_id: this.messageId }, + data: { content: JSON.stringify(errCard) }, + }) + } + } catch (renderErr) { + console.error( + '[Feishu StreamingCard] abort render failed:', + renderErr instanceof Error ? renderErr.message : renderErr, + ) + } finally { + this.flushController.complete() + } + } + + // ------------------------------------------------------------------ + // Internals + // ------------------------------------------------------------------ + + /** 当前应使用的节流时长。 */ + private currentThrottle(): number { + return this.cardKitStreamActive ? THROTTLE.CARDKIT_MS : THROTTLE.PATCH_MS + } + + /** 把 accumulatedText 经 sanitize + optimize 管道出来。 */ + private renderedText(): string { + // 表格数限制在 optimize 之前做 —— sanitize 对原始 markdown 最准 + const limited = sanitizeTextForCard(this.accumulatedText) + return optimizeMarkdownForFeishu(limited, 2) + } + + /** FlushController 调用的 doFlush。 */ + private async performFlush(): Promise { + if (this.phase !== 'streaming') return + if (!this.messageId) return + + // CardKit 中间帧被禁用但 cardId 仍有效 —— 跳过中间 flush, + // 等 finalize 用 cardId 做最终 settings + update + if (this.cardId && !this.cardKitStreamActive) return + + const finalText = this.renderedText() + if (finalText === this.lastFlushedText) return + + if (this.cardKitStreamActive && this.cardId) { + // CardKit 主路径 + this.sequence += 1 + try { + await streamCardContent( + this.deps.larkClient, + this.cardId, + STREAMING_ELEMENT_ID, + finalText, + this.sequence, + ) + this.lastFlushedText = finalText + } catch (err) { + if (isCardRateLimitError(err)) { + // 跳帧 —— 下次 throttledUpdate 会重试 + return + } + if (isCardTableLimitError(err)) { + // 表格超限 —— 禁用流式中间帧,等 finalize 用 update 一次性发完整卡 + console.warn( + '[Feishu StreamingCard] 230099 table limit, disabling CardKit streaming', + ) + this.cardKitStreamActive = false + return + } + // 其他错误 —— 禁用流式,最坏情况等 finalize 兜底 + console.error( + '[Feishu StreamingCard] stream flush failed:', + err instanceof Error ? err.message : err, + ) + this.cardKitStreamActive = false + return + } + } else { + // Patch fallback 路径(CardKit 从未成功) + try { + await this.deps.larkClient.im.message.patch({ + path: { message_id: this.messageId }, + data: { content: JSON.stringify(buildRenderedCard(finalText)) }, + }) + this.lastFlushedText = finalText + } catch (err) { + if (isCardRateLimitError(err)) return + console.error( + '[Feishu StreamingCard] patch flush failed:', + err instanceof Error ? err.message : err, + ) + } + } + } + + // ------------------------------------------------------------------ + // Test helpers (exposed for unit tests, not part of public API) + // ------------------------------------------------------------------ + + /** @internal */ + _getPhase(): StreamingCardPhase { + return this.phase + } + + /** @internal */ + _getCardId(): string | null { + return this.cardId + } + + /** @internal */ + _getMessageId(): string | null { + return this.messageId + } + + /** @internal */ + _getSequence(): number { + return this.sequence + } + + /** @internal */ + _isCardKitStreamActive(): boolean { + return this.cardKitStreamActive + } + + /** @internal */ + _getAccumulatedText(): string { + return this.accumulatedText + } + + /** @internal */ + _getFlushController(): FlushController { + return this.flushController + } +}