feat(feishu): rewrite streaming to CardKit Schema 2.0 with proper markdown

Replace legacy im.message.patch streaming path with a Feishu CardKit
Schema 2.0 pipeline mirroring openclaw-lark's implementation. Desktop
now renders real formatted markdown (headings, tables, code blocks)
instead of literal text, and streaming output updates per chat without
cross-talk.

New modules:
- card-errors.ts: structured Lark SDK error parsing for code 230020
  (rate limit -> skip frame) and 230099/11310 (table limit -> disable
  CardKit streaming and fall back to settings+update at finalize)
- flush-controller.ts: mutex + needsReflush + long-gap batching
  throttler for per-chat flush coordination
- cardkit.ts: thin wrapper over client.cardkit.v1.* (card.create +
  im.message.{create,reply} + cardElement.content + card.settings +
  card.update), with monotonic sequence and CardKitApiError
- streaming-card.ts: per-chat StreamingCard state machine covering
  idle -> creating -> streaming -> finalizing -> completed/aborted,
  including patch fallback when CardKit create/send fails

index.ts rewrite:
- replaces MessageBuffer/chatStates/buffers maps with a single
  streamingCards Map<chatId, StreamingCard>
- content_start{text}/content_delta create or reuse the card; finalize
  runs on message_complete; abort renders a red error card
- /clear and other silent commands stay out of the streaming-card path
  so they don't leave empty cards
- all command handlers + normal chat are wrapped in the per-chat
  enqueue() serial queue, preventing reply reordering when commands
  are fired rapidly
- createSessionForChat() now first resets any stale WS session before
  calling connectSession, fixing /projects pick_project leaving the
  old session bound to the previous workDir
- normal messages pre-create the card before sendUserMessage so users
  see a "☁️ 正在思考中..." loading indicator immediately
- content_start{tool_use} no longer finalizes the current card, letting
  one user turn's full text stay in a single card

markdown-style.ts:
- default cardVersion changed from 1 to 2 (Schema 2.0)
- headings H2~H6 demoted to H5, H1 to H4, only when source has H1~H3
- Schema 2.0 <br> padding around headings, tables, code blocks
- stripInvalidImageKeys() drops non-img_* image references
- FEISHU_CARD_TABLE_LIMIT=3 + sanitizeTextForCard() wraps 4th+ table
  in a fenced code block to avoid 230099/11310

Tests: 182 pass, 0 fail, 383 expect() calls across 6 files. tsc clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-11 21:04:22 +08:00
parent 413437ced0
commit ae586b0d9b
11 changed files with 2941 additions and 333 deletions

View File

@ -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)
})
})

View File

@ -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<string, any>) {
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 payloadpath 包含 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')
})
})

View File

@ -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<void> | null = null
const doFlush = async () => {
calls.push('flush-start')
if (latch) {
await latch
latch = null
}
calls.push('flush-end')
}
const blockNext = () => {
latch = new Promise<void>((resolve) => {
resolveCurrent = resolve
})
}
const unblock = () => {
if (resolveCurrent) {
const r = resolveCurrent
resolveCurrent = null
r()
}
}
return { doFlush, calls, blockNext, unblock }
}
async function sleep(ms: number): Promise<void> {
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)
})
})

View File

@ -1,139 +1,318 @@
/**
* optimizeMarkdownForFeishu
* markdown-style
*
* :
* - H1~H3 (workaround Feishu tag:'markdown' H1~H3 )
* - #
* - (H1~H3 workaround)
* -
* -
* - 边界场景: 无标题不动H4+
* - Schema 2.0: 连续标题// <br>
* - 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: <br> 间距
// ---------------------------------------------------------------------------
describe('optimizeMarkdownForFeishu: Schema 2.0 <br> 间距', () => {
it('cardVersion=2 默认在代码块前后加 <br>', () => {
const out = opt('text\n\n```\ncode\n```')
// 代码块前后应包裹 <br>
expect(out).toContain('<br>\n```')
expect(out).toContain('```\n<br>')
})
it('cardVersion=1 代码块前后不加 <br>', () => {
const out = opt('text\n\n```\ncode\n```', 1)
expect(out).not.toContain('<br>')
})
it('cardVersion=2 连续标题之间加 <br>', () => {
const out = opt('# A\n# B')
// H1 降级为 H4之间插入 <br>
expect(out).toMatch(/#### A\n<br>\n#### B/)
})
it('cardVersion=2 表格前后加 <br>', () => {
const input = 'text before\n\n| col1 | col2 |\n|------|------|\n| v1 | v2 |\n\ntext after'
const out = opt(input)
// 表格前: <br> 紧贴文本行(规则 3e 压缩多余空行)
expect(out).toMatch(/text before\n<br>\n\| col1/)
// 表格后: <br> 跟两个换行到下一段文本
expect(out).toMatch(/\| v1 \| v2 \|\n<br>\n\ntext after/)
})
it('cardVersion=1 表格前后不加 <br>', () => {
const input = 'text before\n\n| col1 | col2 |\n|------|------|\n| v1 | v2 |\n\ntext after'
const out = opt(input, 1)
expect(out).not.toContain('<br>')
})
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 标记(因为代码块内不算表格)
// 代码块本身会被 <br> 包裹Schema 2.0)但不会在 | 周围单独加 <br>
expect(out).toMatch(/<br>\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)
// 代码块前后有 <br>Schema 2.0 默认)
expect(out).toContain('<br>\n```')
expect(out).toContain('```\n<br>')
})
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 (默认) 不加段落间距 <br>', () => {
const input = '# A\n## B'
const out = optimizeMarkdownForFeishu(input)
expect(out).not.toContain('<br>')
})
it('cardVersion=2 在连续降级标题之间加 <br>', () => {
const input = '# A\n# B'
const out = optimizeMarkdownForFeishu(input, 2)
// 两个 H1 都降级为 H4之间插入 <br>
expect(out).toContain('#### A\n<br>\n#### B')
expect(opt('')).toBe('')
})
})

View File

@ -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.createsequence=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.contentsequence 单调递增', 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 进入节流窗口(刚 readylastUpdateTime 还新)
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.updatesequence 连续递增', 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 + updatecardId 还在)
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')
})
})

View File

@ -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)
)
}

294
adapters/feishu/cardkit.ts Normal file
View File

@ -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<string, unknown>
[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<string, unknown>,
): Promise<string> {
// 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<string> {
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<void> {
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<void> {
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 templatefooter
*/
export async function updateCardKitCard(
client: LarkClient,
cardId: string,
card: Record<string, unknown>,
sequence: number,
): Promise<void> {
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}`,
})
}

View File

@ -0,0 +1,149 @@
/**
* + mutex + flush
*
* markdown
* flush doFlush
*
* :
* - throttledUpdate(throttleMs)
* - flush() mutex
* - flush needsReflushAPI
* - > 2000ms flush 300ms
* - complete() flush
*
* 参考实现: openclaw-lark/src/card/flush-controller.ts
*/
// ---------------------------------------------------------------------------
// Throttle constants
// ---------------------------------------------------------------------------
export const THROTTLE = {
/** CardKit cardElement.content() 最小间隔 —— 官方为流式设计,可高频 */
CARDKIT_MS: 100,
/** im.message.patch 最小间隔 —— 严格速率限制230020 */
PATCH_MS: 1500,
/** 长间隔判定阈值。elapsed > 2000ms 触发批量模式 */
LONG_GAP_THRESHOLD_MS: 2000,
/** 长间隔后第一帧的额外延迟,让文本积累更完整 */
BATCH_AFTER_GAP_MS: 300,
} as const
// ---------------------------------------------------------------------------
// FlushController
// ---------------------------------------------------------------------------
export class FlushController {
private flushInProgress = false
private flushResolvers: Array<() => void> = []
private needsReflush = false
private pendingFlushTimer: ReturnType<typeof setTimeout> | null = null
private lastUpdateTime = 0
private isCompleted = false
private _cardMessageReady = false
constructor(private readonly doFlush: () => Promise<void>) {}
/** 标记完成 —— 当前 flush 跑完后不再接受新的。 */
complete(): void {
this.isCompleted = true
}
/** 取消任何挂起的延迟 flush 计时器。 */
cancelPendingFlush(): void {
if (this.pendingFlushTimer) {
clearTimeout(this.pendingFlushTimer)
this.pendingFlushTimer = null
}
}
/** 等待当前正在跑的 flush 结束。没在跑则立即返回。 */
waitForFlush(): Promise<void> {
if (!this.flushInProgress) return Promise.resolve()
return new Promise<void>((resolve) => this.flushResolvers.push(resolve))
}
/**
* flush
*
* true lastUpdateTime throttledUpdate
* elapsed openclaw "card 创建完立即可刷"
*/
setCardMessageReady(ready: boolean): void {
this._cardMessageReady = ready
if (ready) this.lastUpdateTime = Date.now()
}
cardMessageReady(): boolean {
return this._cardMessageReady
}
/**
* flushmutex +
*
* flush needsReflush flush
*/
async flush(): Promise<void> {
if (!this.cardMessageReady() || this.flushInProgress || this.isCompleted) {
if (this.flushInProgress && !this.isCompleted) this.needsReflush = true
return
}
this.flushInProgress = true
this.needsReflush = false
// 在 API 调用 **之前** 更新时间戳,防止并发调用者也进入 flush
this.lastUpdateTime = Date.now()
try {
await this.doFlush()
this.lastUpdateTime = Date.now()
} finally {
this.flushInProgress = false
const resolvers = this.flushResolvers
this.flushResolvers = []
for (const resolve of resolvers) resolve()
// 如果 API 调用期间有新事件进来,立即补一次 flush
if (this.needsReflush && !this.isCompleted && !this.pendingFlushTimer) {
this.needsReflush = false
this.pendingFlushTimer = setTimeout(() => {
this.pendingFlushTimer = null
void this.flush()
}, 0)
}
}
}
/**
*
*
* @param throttleMs - flush CardKit THROTTLE.CARDKIT_MS
* Patch THROTTLE.PATCH_MS
*/
async throttledUpdate(throttleMs: number): Promise<void> {
if (!this.cardMessageReady()) return
const now = Date.now()
const elapsed = now - this.lastUpdateTime
if (elapsed >= throttleMs) {
this.cancelPendingFlush()
if (elapsed > THROTTLE.LONG_GAP_THRESHOLD_MS) {
// 长间隔批量模式:工具调用 / 推理回来后的第一帧延迟 300ms
// 让文本积累更完整,避免只显示一两个字
this.lastUpdateTime = now
this.pendingFlushTimer = setTimeout(() => {
this.pendingFlushTimer = null
void this.flush()
}, THROTTLE.BATCH_AFTER_GAP_MS)
} else {
await this.flush()
}
} else if (!this.pendingFlushTimer) {
// 在节流窗口内 —— 延迟到窗口结束再刷
const delay = throttleMs - elapsed
this.pendingFlushTimer = setTimeout(() => {
this.pendingFlushTimer = null
void this.flush()
}, delay)
}
}
}

View File

@ -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<string, ChatState>()
const buffers = new Map<string, MessageBuffer>()
const accumulatedText = new Map<string, string>()
// One streaming card lifecycle per chatId (CardKit main + patch fallback).
const streamingCards = new Map<string, StreamingCard>()
const pendingProjectSelection = new Map<string, boolean>()
const runtimeStates = new Map<string, ChatRuntimeState>()
@ -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<void> {
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<void> {
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<string, unknown>): 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 H1H4 and H2~H6H5. We do the same here. */
function buildStreamingCard(text: string): Record<string, unknown> {
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<void> {
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<void> {
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<boolean> {
@ -635,6 +570,18 @@ async function ensureSession(chatId: string): Promise<boolean> {
async function createSessionForChat(chatId: string, workDir: string): Promise<boolean> {
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<void> {
async function startNewSession(chatId: string, query?: string): Promise<void> {
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<void> {
// ---------- server message handler ----------
async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<void> {
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<void> {
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 重新开始。')
}

View File

@ -1,29 +1,47 @@
/**
* Feishu Markdown
*
* 背景: 飞书卡片的 `tag: 'markdown'` H1~H3
* `#`/`##`/`###` H4/H5
* :
* - `tag: 'markdown'` H1~H3
* H4/H5
* - Schema 2.0 CardKit markdown `<br>` //
*
* - FEISHU_CARD_TABLE_LIMIT=3 230099/11310
* - `img_xxx` key URL CardKit 200570
*
* 实现参考: openclaw-lark/src/card/markdown-style.ts
*
* Schema 2.0
* <br> cardVersion 便
* 实现参考: openclaw-lark/src/card/markdown-style.ts + card-error.ts
*/
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** 飞书卡片表格数量上限 —— 超出 3 张触发 230099/11310openclaw 2026-03 实测) */
export const FEISHU_CARD_TABLE_LIMIT = 3
// ---------------------------------------------------------------------------
// Public: optimizeMarkdownForFeishu
// ---------------------------------------------------------------------------
/**
* `tag: 'markdown'`
* `tag: 'markdown'` markdown
*
* - 标题降级: 若原文包含 H1~H3 H2~H6 H5H1 H4
* -
* - Schema 2.0: 连续标题// `<br>`
* - 3+ 2
* - `img_*` markdown CardKit 200570
* - fallback
*
* @param text markdown
* @param cardVersion schema <br>
* @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) {
// 连续标题之间补 <br>
// 3a. 连续标题之间加 <br>
r = r.replace(/^(#{4,5} .+)\n{1,2}(#{4,5} )/gm, '$1\n<br>\n$2')
// 3b. 非表格行直接跟表格行 → 先补空行(保证后续规则生效)
r = r.replace(/^([^|\n].*)\n(\|.+\|)/gm, '$1\n\n$2')
// 3c. 表格前: 在空行之前插入 <br>(即 `\n\n|` → `\n<br>\n\n|`
r = r.replace(/\n\n((?:\|.+\|[^\S\n]*\n?)+)/g, '\n\n<br>\n\n$1')
// 3d. 表格后: 在表格块末尾追加 <br>(跳过后接分隔线/标题/加粗/文末)
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<br>\n'
})
// 3e. 表格前是普通文本: 只保留 <br>,去掉多余空行
// "text\n\n<br>\n\n|" → "text\n<br>\n|"
r = r.replace(/^((?!#{4,5} )(?!\*\*).+)\n\n(<br>)\n\n(\|)/gm, '$1\n$2\n$3')
// 3f. 表格前是加粗行: <br> 紧贴加粗行,空行保留在后面
// "**bold**\n\n<br>\n\n|" → "**bold**\n<br>\n\n|"
r = r.replace(/^(\*\*.+)\n\n(<br>)\n\n(\|)/gm, '$1\n$2\n\n$3')
// 3g. 表格后是普通文本: 去掉多余空行
// "| row |\n\n<br>\ntext" → "| row |\n<br>\ntext"
r = r.replace(/(\|[^\n]*\n)\n(<br>\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 路径不在代码块前后加 <br>(维持现状,最小变更)
// Schema 2.0 时前后加 <br>,让代码块与周围段落拉开距离
codeBlocks.forEach((block, i) => {
r = r.replace(`${MARK}${i}___`, block)
const replacement = cardVersion >= 2 ? `\n<br>\n${block}\n<br>\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 200570unknown 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
}

View File

@ -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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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+updatecardId */
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<void> {
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<void> {
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<void> {
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<void> {
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
}
}