mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
413437ced0
commit
ae586b0d9b
194
adapters/feishu/__tests__/card-errors.test.ts
Normal file
194
adapters/feishu/__tests__/card-errors.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
295
adapters/feishu/__tests__/cardkit.test.ts
Normal file
295
adapters/feishu/__tests__/cardkit.test.ts
Normal 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 payload,path 包含 card_id + element_id', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'cardElement.content': { code: 0 },
|
||||
})
|
||||
await streamCardContent(client, 'ck_abc', STREAMING_ELEMENT_ID, 'hello', 42)
|
||||
|
||||
expect(calls.length).toBe(1)
|
||||
expect(calls[0]!.api).toBe('cardkit.v1.cardElement.content')
|
||||
expect(calls[0]!.args.data).toEqual({ content: 'hello', sequence: 42 })
|
||||
expect(calls[0]!.args.path).toEqual({
|
||||
card_id: 'ck_abc',
|
||||
element_id: STREAMING_ELEMENT_ID,
|
||||
})
|
||||
})
|
||||
|
||||
it('STREAMING_ELEMENT_ID 常量 = "streaming_content"', () => {
|
||||
expect(STREAMING_ELEMENT_ID).toBe('streaming_content')
|
||||
})
|
||||
|
||||
it('230020 响应可被 isCardRateLimitError 识别', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'cardElement.content': { code: 230020, msg: 'rate limited' },
|
||||
})
|
||||
try {
|
||||
await streamCardContent(client, 'ck', 'el', 'x', 1)
|
||||
expect('should have thrown').toBe('but did not')
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CardKitApiError)
|
||||
expect(isCardRateLimitError(err)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('230099 + table limit msg 可被 isCardTableLimitError 识别', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'cardElement.content': {
|
||||
code: 230099,
|
||||
msg: 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; ',
|
||||
},
|
||||
})
|
||||
try {
|
||||
await streamCardContent(client, 'ck', 'el', 'x', 1)
|
||||
expect('should have thrown').toBe('but did not')
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CardKitApiError)
|
||||
expect(isCardTableLimitError(err)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setCardStreamingMode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('setCardStreamingMode', () => {
|
||||
it('streaming_mode=false + sequence 正确传递', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.settings': { code: 0 },
|
||||
})
|
||||
await setCardStreamingMode(client, 'ck_xxx', false, 99)
|
||||
|
||||
expect(calls.length).toBe(1)
|
||||
expect(calls[0]!.api).toBe('cardkit.v1.card.settings')
|
||||
expect(calls[0]!.args.path).toEqual({ card_id: 'ck_xxx' })
|
||||
expect(calls[0]!.args.data.sequence).toBe(99)
|
||||
// settings 是 JSON 字符串
|
||||
const settings = JSON.parse(calls[0]!.args.data.settings)
|
||||
expect(settings).toEqual({ streaming_mode: false })
|
||||
})
|
||||
|
||||
it('streaming_mode=true 也能工作', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.settings': { code: 0 },
|
||||
})
|
||||
await setCardStreamingMode(client, 'ck', true, 1)
|
||||
const settings = JSON.parse(calls[0]!.args.data.settings)
|
||||
expect(settings).toEqual({ streaming_mode: true })
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateCardKitCard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('updateCardKitCard', () => {
|
||||
it('把 card 包装成 card_json payload + sequence', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.update': { code: 0 },
|
||||
})
|
||||
const card = { schema: '2.0', body: { elements: [{ tag: 'markdown', content: 'done' }] } }
|
||||
await updateCardKitCard(client, 'ck_final', card, 100)
|
||||
|
||||
expect(calls.length).toBe(1)
|
||||
expect(calls[0]!.api).toBe('cardkit.v1.card.update')
|
||||
expect(calls[0]!.args.path).toEqual({ card_id: 'ck_final' })
|
||||
expect(calls[0]!.args.data.sequence).toBe(100)
|
||||
expect(calls[0]!.args.data.card.type).toBe('card_json')
|
||||
expect(calls[0]!.args.data.card.data).toBe(JSON.stringify(card))
|
||||
})
|
||||
|
||||
it('非零 code 抛 CardKitApiError', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'card.update': { code: -1, msg: 'bad card' },
|
||||
})
|
||||
await expect(updateCardKitCard(client, 'ck', {}, 1)).rejects.toThrow(CardKitApiError)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CardKitApiError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('CardKitApiError', () => {
|
||||
it('携带 code 和 msg,可被 parseCardApiError 识别', () => {
|
||||
const err = new CardKitApiError({
|
||||
api: 'card.update',
|
||||
code: 230020,
|
||||
msg: 'rate limited',
|
||||
context: 'seq=5',
|
||||
})
|
||||
expect(err.code).toBe(230020)
|
||||
expect(err.msg).toBe('rate limited')
|
||||
expect(err.name).toBe('CardKitApiError')
|
||||
expect(isCardRateLimitError(err)).toBe(true)
|
||||
})
|
||||
|
||||
it('消息包含 api 名和 context', () => {
|
||||
const err = new CardKitApiError({
|
||||
api: 'cardElement.content',
|
||||
code: 230099,
|
||||
msg: 'oops',
|
||||
context: 'seq=3 len=100',
|
||||
})
|
||||
expect(err.message).toContain('cardElement.content')
|
||||
expect(err.message).toContain('230099')
|
||||
expect(err.message).toContain('seq=3 len=100')
|
||||
})
|
||||
})
|
||||
290
adapters/feishu/__tests__/flush-controller.test.ts
Normal file
290
adapters/feishu/__tests__/flush-controller.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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('前缀  后缀')
|
||||
expect(out).toContain('')
|
||||
})
|
||||
|
||||
it('http:// URL 图片被删除', () => {
|
||||
const out = opt('前缀  后缀')
|
||||
expect(out).toBe('前缀 后缀')
|
||||
})
|
||||
|
||||
it('https:// URL 图片被删除', () => {
|
||||
const out = opt('')
|
||||
expect(out).toBe('')
|
||||
})
|
||||
|
||||
it('本地路径被删除', () => {
|
||||
const out = opt('')
|
||||
expect(out).toBe('')
|
||||
})
|
||||
|
||||
it('无图片文本原样', () => {
|
||||
expect(opt('no images here', 1)).toBe('no images here')
|
||||
})
|
||||
|
||||
it('混合: img_ 保留,URL 删除', () => {
|
||||
const out = opt(' 和 ')
|
||||
expect(out).toContain('')
|
||||
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('')
|
||||
})
|
||||
})
|
||||
|
||||
523
adapters/feishu/__tests__/streaming-card.test.ts
Normal file
523
adapters/feishu/__tests__/streaming-card.test.ts
Normal 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.create,sequence=1', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck_main_1' } },
|
||||
'im.message.create': { data: { message_id: 'om_main_1' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'oc_chat_1' })
|
||||
await sc.ensureCreated()
|
||||
|
||||
expect(sc._getPhase()).toBe('streaming')
|
||||
expect(sc._getCardId()).toBe('ck_main_1')
|
||||
expect(sc._getMessageId()).toBe('om_main_1')
|
||||
expect(sc._getSequence()).toBe(1)
|
||||
expect(sc._isCardKitStreamActive()).toBe(true)
|
||||
|
||||
expect(calls[0]!.api).toBe('cardkit.v1.card.create')
|
||||
expect(calls[1]!.api).toBe('im.message.create')
|
||||
|
||||
// 初始卡 JSON 包含 streaming_mode 和 element_id
|
||||
const cardJson = JSON.parse(calls[0]!.args.data.data)
|
||||
expect(cardJson.schema).toBe('2.0')
|
||||
expect(cardJson.config.streaming_mode).toBe(true)
|
||||
// loading 元素在首位,streaming_content 占位在第二
|
||||
expect(cardJson.body.elements[1].element_id).toBe(STREAMING_ELEMENT_ID)
|
||||
|
||||
// IM message 引用 card_id
|
||||
const content = JSON.parse(calls[1]!.args.data.content)
|
||||
expect(content).toEqual({ type: 'card', data: { card_id: 'ck_main_1' } })
|
||||
})
|
||||
|
||||
it('幂等: 重复调用 ensureCreated 不重复创建', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck_1' } },
|
||||
'im.message.create': { data: { message_id: 'om_1' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
await sc.ensureCreated()
|
||||
await sc.ensureCreated()
|
||||
// 只一次 create + 一次 send
|
||||
const createCalls = calls.filter((c) => c.api === 'cardkit.v1.card.create')
|
||||
const sendCalls = calls.filter((c) => c.api === 'im.message.create')
|
||||
expect(createCalls.length).toBe(1)
|
||||
expect(sendCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
it('replyToMessageId 走 im.message.reply 而非 create', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck' } },
|
||||
'im.message.reply': { data: { message_id: 'om_reply' } },
|
||||
})
|
||||
const sc = new StreamingCard({
|
||||
larkClient: client,
|
||||
chatId: 'c',
|
||||
replyToMessageId: 'om_parent',
|
||||
})
|
||||
await sc.ensureCreated()
|
||||
expect(calls.some((c) => c.api === 'im.message.reply')).toBe(true)
|
||||
expect(calls.some((c) => c.api === 'im.message.create')).toBe(false)
|
||||
expect(sc._getMessageId()).toBe('om_reply')
|
||||
})
|
||||
})
|
||||
|
||||
describe('StreamingCard: ensureCreated (fallback 降级路径)', () => {
|
||||
it('CardKit create 失败 → 直发 Schema 2.0 卡 + patch 模式', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 99991672, msg: 'permission denied' },
|
||||
'im.message.create': { data: { message_id: 'om_fb' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
|
||||
expect(sc._getPhase()).toBe('streaming')
|
||||
expect(sc._getCardId()).toBeNull()
|
||||
expect(sc._getMessageId()).toBe('om_fb')
|
||||
expect(sc._isCardKitStreamActive()).toBe(false)
|
||||
|
||||
// fallback 发送的是 Schema 2.0 interactive 卡
|
||||
const createCall = calls.find((c) => c.api === 'im.message.create')
|
||||
expect(createCall).toBeDefined()
|
||||
expect(createCall!.args.data.msg_type).toBe('interactive')
|
||||
const cardContent = JSON.parse(createCall!.args.data.content)
|
||||
expect(cardContent.schema).toBe('2.0')
|
||||
})
|
||||
|
||||
it('CardKit send 失败(create 成功但 im.message.create 失败)也能降级', async () => {
|
||||
let sendCallCount = 0
|
||||
const { client } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck' } },
|
||||
'im.message.create': () => {
|
||||
sendCallCount++
|
||||
if (sendCallCount === 1) throw new Error('send failed')
|
||||
return { data: { message_id: 'om_fb2' } }
|
||||
},
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
expect(sc._getPhase()).toBe('streaming')
|
||||
expect(sc._getCardId()).toBeNull()
|
||||
expect(sc._getMessageId()).toBe('om_fb2')
|
||||
})
|
||||
|
||||
it('降级发送也失败 → aborted + throw', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'card.create': { code: 99991672 },
|
||||
'im.message.create': () => {
|
||||
throw new Error('really broken')
|
||||
},
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await expect(sc.ensureCreated()).rejects.toThrow()
|
||||
expect(sc._getPhase()).toBe('aborted')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// appendText + flush
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('StreamingCard: appendText + flush', () => {
|
||||
it('accumulated 文本写入 cardElement.content,sequence 单调递增', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck_stream' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
|
||||
// 第一次 appendText 进入节流窗口(刚 ready,lastUpdateTime 还新)
|
||||
sc.appendText('Hello ')
|
||||
sc.appendText('world')
|
||||
|
||||
// 节流窗口 100ms + 余量
|
||||
await sleep(150)
|
||||
|
||||
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
||||
expect(contentCalls.length).toBeGreaterThan(0)
|
||||
// 最后一次 flush 的内容应包含完整累积文本
|
||||
const lastCall = contentCalls[contentCalls.length - 1]!
|
||||
expect(lastCall.args.data.content).toContain('Hello world')
|
||||
expect(lastCall.args.path.element_id).toBe(STREAMING_ELEMENT_ID)
|
||||
// sequence 严格单调递增
|
||||
const seqs = contentCalls.map((c) => c.args.data.sequence)
|
||||
for (let i = 1; i < seqs.length; i++) {
|
||||
expect(seqs[i]).toBeGreaterThan(seqs[i - 1]!)
|
||||
}
|
||||
})
|
||||
|
||||
it('内容未变化时不重复 flush(基于 lastFlushedText 对比)', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
|
||||
sc.appendText('same')
|
||||
await sleep(150)
|
||||
|
||||
// 强制再跑一次 flush(无新文本)
|
||||
await sc._getFlushController().flush()
|
||||
|
||||
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
||||
// 应该只有一次 content 调用
|
||||
expect(contentCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
it('completed 之后的 appendText 被忽略', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
await sc.finalize()
|
||||
sc.appendText('ignored')
|
||||
expect(sc._getAccumulatedText()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// finalize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('StreamingCard: finalize', () => {
|
||||
it('CardKit 路径: settings(false) + card.update,sequence 连续递增', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck_final' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
sc.appendText('# Title\n\nBody')
|
||||
await sleep(150)
|
||||
const contentSeqs = calls
|
||||
.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
||||
.map((c) => c.args.data.sequence)
|
||||
const lastContentSeq = contentSeqs[contentSeqs.length - 1] ?? 1
|
||||
|
||||
await sc.finalize()
|
||||
|
||||
expect(sc._getPhase()).toBe('completed')
|
||||
|
||||
const settingsCalls = calls.filter((c) => c.api === 'cardkit.v1.card.settings')
|
||||
const updateCalls = calls.filter((c) => c.api === 'cardkit.v1.card.update')
|
||||
expect(settingsCalls.length).toBe(1)
|
||||
expect(updateCalls.length).toBe(1)
|
||||
|
||||
const settingsSeq = settingsCalls[0]!.args.data.sequence
|
||||
const updateSeq = updateCalls[0]!.args.data.sequence
|
||||
expect(settingsSeq).toBeGreaterThan(lastContentSeq)
|
||||
expect(updateSeq).toBeGreaterThan(settingsSeq)
|
||||
|
||||
// settings 关闭 streaming_mode
|
||||
const settings = JSON.parse(settingsCalls[0]!.args.data.settings)
|
||||
expect(settings.streaming_mode).toBe(false)
|
||||
|
||||
// update 卡内容是预处理后的 markdown
|
||||
const finalCardJson = JSON.parse(updateCalls[0]!.args.data.card.data)
|
||||
const finalContent = finalCardJson.body.elements[0].content
|
||||
// H1 被降级为 H4
|
||||
expect(finalContent).toContain('#### Title')
|
||||
expect(finalContent).toContain('Body')
|
||||
})
|
||||
|
||||
it('Fallback 路径: im.message.patch 发完整渲染卡', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 99991672 },
|
||||
'im.message.create': { data: { message_id: 'om_fb' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
sc.appendText('## Heading\n\nContent')
|
||||
await sleep(1600) // 等 PATCH_MS 窗口
|
||||
await sc.finalize()
|
||||
|
||||
const patchCalls = calls.filter((c) => c.api === 'im.message.patch')
|
||||
expect(patchCalls.length).toBeGreaterThan(0)
|
||||
// 最后一次 patch 是 finalize 的(full final card)
|
||||
const lastPatch = patchCalls[patchCalls.length - 1]!
|
||||
const finalCard = JSON.parse(lastPatch.args.data.content)
|
||||
const finalContent = finalCard.body.elements[0].content
|
||||
// ## → ##### 降级
|
||||
expect(finalContent).toContain('##### Heading')
|
||||
})
|
||||
|
||||
it('完全 idle 时 finalize 直接标记 completed 不抛错', async () => {
|
||||
const { client } = makeMockClient()
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.finalize()
|
||||
expect(sc._getPhase()).toBe('completed')
|
||||
})
|
||||
|
||||
it('finalize 失败不抛出', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
'card.settings': () => {
|
||||
throw new Error('settings exploded')
|
||||
},
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
sc.appendText('text')
|
||||
await sleep(150)
|
||||
// finalize 内部捕获错误不 rethrow
|
||||
await sc.finalize()
|
||||
expect(sc._getPhase()).toBe('completed')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rate limit + table limit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('StreamingCard: 错误处理', () => {
|
||||
it('230020 rate limit → 跳帧,后续 flush 继续', async () => {
|
||||
let callIdx = 0
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
'cardElement.content': () => {
|
||||
const i = callIdx++
|
||||
if (i === 0) {
|
||||
const err: any = new Error('rate limit')
|
||||
err.code = 230020
|
||||
throw err
|
||||
}
|
||||
return { code: 0 }
|
||||
},
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
sc.appendText('first')
|
||||
await sleep(150)
|
||||
// 第一次被限流
|
||||
sc.appendText(' second')
|
||||
await sleep(150)
|
||||
// 第二次应能成功
|
||||
|
||||
// CardKit 仍然 active(没降级)
|
||||
expect(sc._isCardKitStreamActive()).toBe(true)
|
||||
const contentCalls = calls.filter((c) => c.api === 'cardkit.v1.cardElement.content')
|
||||
expect(contentCalls.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('230099 table limit → 禁用流式但 cardId 保留,finalize 仍走 CardKit', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck_tbl' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
'cardElement.content': () => {
|
||||
const err: any = new Error('content failed')
|
||||
err.code = 230099
|
||||
err.msg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; '
|
||||
throw err
|
||||
},
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
sc.appendText('some content')
|
||||
await sleep(150)
|
||||
expect(sc._isCardKitStreamActive()).toBe(false)
|
||||
expect(sc._getCardId()).toBe('ck_tbl') // card_id 保留
|
||||
|
||||
await sc.finalize()
|
||||
// finalize 仍然走 CardKit 的 settings + update(cardId 还在)
|
||||
expect(calls.some((c) => c.api === 'cardkit.v1.card.settings')).toBe(true)
|
||||
expect(calls.some((c) => c.api === 'cardkit.v1.card.update')).toBe(true)
|
||||
// 不走 patch
|
||||
expect(calls.some((c) => c.api === 'im.message.patch')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// abort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('StreamingCard: abort', () => {
|
||||
it('CardKit 路径: 渲染错误卡并关闭流式', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': { code: 0, data: { card_id: 'ck_err' } },
|
||||
'im.message.create': { data: { message_id: 'om' } },
|
||||
})
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.ensureCreated()
|
||||
sc.appendText('partial...')
|
||||
await sleep(150)
|
||||
|
||||
await sc.abort(new Error('something went wrong'))
|
||||
expect(sc._getPhase()).toBe('aborted')
|
||||
|
||||
const updateCalls = calls.filter((c) => c.api === 'cardkit.v1.card.update')
|
||||
expect(updateCalls.length).toBeGreaterThan(0)
|
||||
const errCard = JSON.parse(updateCalls[updateCalls.length - 1]!.args.data.card.data)
|
||||
expect(errCard.header.template).toBe('red')
|
||||
expect(errCard.body.elements[0].content).toContain('something went wrong')
|
||||
// 保留已累积的部分文本
|
||||
expect(errCard.body.elements[0].content).toContain('partial...')
|
||||
})
|
||||
|
||||
it('idle 阶段 abort 不抛错', async () => {
|
||||
const { client } = makeMockClient()
|
||||
const sc = new StreamingCard({ larkClient: client, chatId: 'c' })
|
||||
await sc.abort(new Error('before any card'))
|
||||
expect(sc._getPhase()).toBe('aborted')
|
||||
})
|
||||
})
|
||||
151
adapters/feishu/card-errors.ts
Normal file
151
adapters/feishu/card-errors.ts
Normal 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
294
adapters/feishu/cardkit.ts
Normal 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 template、footer、完成样式等)。
|
||||
*/
|
||||
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}`,
|
||||
})
|
||||
}
|
||||
149
adapters/feishu/flush-controller.ts
Normal file
149
adapters/feishu/flush-controller.ts
Normal file
@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 节流 + mutex + 冲突重刷的通用 flush 调度器
|
||||
*
|
||||
* 这是个纯调度原语 —— 不含任何业务逻辑(发送卡片、构造 markdown 等)。
|
||||
* 实际 flush 工作由构造函数注入的 doFlush 回调负责。
|
||||
*
|
||||
* 语义:
|
||||
* - throttledUpdate(throttleMs) 被流式数据触发,按窗口节流
|
||||
* - flush() 被 mutex 保护,相同时刻只有一个在跑
|
||||
* - flush 进行中的新数据标记 needsReflush,API 结束后立即补刷
|
||||
* - 长间隔(> 2000ms)后的第一次 flush 延迟 300ms 批量,避免抖动
|
||||
* - complete() 后拒绝所有新 flush
|
||||
*
|
||||
* 参考实现: openclaw-lark/src/card/flush-controller.ts
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Throttle constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const THROTTLE = {
|
||||
/** CardKit cardElement.content() 最小间隔 —— 官方为流式设计,可高频 */
|
||||
CARDKIT_MS: 100,
|
||||
/** im.message.patch 最小间隔 —— 严格速率限制(230020) */
|
||||
PATCH_MS: 1500,
|
||||
/** 长间隔判定阈值。elapsed > 2000ms 触发批量模式 */
|
||||
LONG_GAP_THRESHOLD_MS: 2000,
|
||||
/** 长间隔后第一帧的额外延迟,让文本积累更完整 */
|
||||
BATCH_AFTER_GAP_MS: 300,
|
||||
} as const
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FlushController
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class FlushController {
|
||||
private flushInProgress = false
|
||||
private flushResolvers: Array<() => void> = []
|
||||
private needsReflush = false
|
||||
private pendingFlushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private lastUpdateTime = 0
|
||||
private isCompleted = false
|
||||
private _cardMessageReady = false
|
||||
|
||||
constructor(private readonly doFlush: () => Promise<void>) {}
|
||||
|
||||
/** 标记完成 —— 当前 flush 跑完后不再接受新的。 */
|
||||
complete(): void {
|
||||
this.isCompleted = true
|
||||
}
|
||||
|
||||
/** 取消任何挂起的延迟 flush 计时器。 */
|
||||
cancelPendingFlush(): void {
|
||||
if (this.pendingFlushTimer) {
|
||||
clearTimeout(this.pendingFlushTimer)
|
||||
this.pendingFlushTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 等待当前正在跑的 flush 结束。没在跑则立即返回。 */
|
||||
waitForFlush(): Promise<void> {
|
||||
if (!this.flushInProgress) return Promise.resolve()
|
||||
return new Promise<void>((resolve) => this.flushResolvers.push(resolve))
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记卡片消息是否已发送成功,决定 flush 是否被放行。
|
||||
*
|
||||
* 首次变 true 时同步更新 lastUpdateTime,让第一次 throttledUpdate
|
||||
* 看到一个小的 elapsed,匹配 openclaw 的 "card 创建完立即可刷" 行为。
|
||||
*/
|
||||
setCardMessageReady(ready: boolean): void {
|
||||
this._cardMessageReady = ready
|
||||
if (ready) this.lastUpdateTime = Date.now()
|
||||
}
|
||||
|
||||
cardMessageReady(): boolean {
|
||||
return this._cardMessageReady
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一次 flush(mutex 保护 + 冲突重刷)。
|
||||
*
|
||||
* 如果已有 flush 在跑,设置 needsReflush,当前 flush 结束后自动补一次。
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
if (!this.cardMessageReady() || this.flushInProgress || this.isCompleted) {
|
||||
if (this.flushInProgress && !this.isCompleted) this.needsReflush = true
|
||||
return
|
||||
}
|
||||
this.flushInProgress = true
|
||||
this.needsReflush = false
|
||||
// 在 API 调用 **之前** 更新时间戳,防止并发调用者也进入 flush
|
||||
this.lastUpdateTime = Date.now()
|
||||
try {
|
||||
await this.doFlush()
|
||||
this.lastUpdateTime = Date.now()
|
||||
} finally {
|
||||
this.flushInProgress = false
|
||||
const resolvers = this.flushResolvers
|
||||
this.flushResolvers = []
|
||||
for (const resolve of resolvers) resolve()
|
||||
|
||||
// 如果 API 调用期间有新事件进来,立即补一次 flush
|
||||
if (this.needsReflush && !this.isCompleted && !this.pendingFlushTimer) {
|
||||
this.needsReflush = false
|
||||
this.pendingFlushTimer = setTimeout(() => {
|
||||
this.pendingFlushTimer = null
|
||||
void this.flush()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 节流更新入口。
|
||||
*
|
||||
* @param throttleMs - 最小 flush 间隔。CardKit 传 THROTTLE.CARDKIT_MS,
|
||||
* Patch 降级路径传 THROTTLE.PATCH_MS。
|
||||
*/
|
||||
async throttledUpdate(throttleMs: number): Promise<void> {
|
||||
if (!this.cardMessageReady()) return
|
||||
|
||||
const now = Date.now()
|
||||
const elapsed = now - this.lastUpdateTime
|
||||
|
||||
if (elapsed >= throttleMs) {
|
||||
this.cancelPendingFlush()
|
||||
if (elapsed > THROTTLE.LONG_GAP_THRESHOLD_MS) {
|
||||
// 长间隔批量模式:工具调用 / 推理回来后的第一帧延迟 300ms
|
||||
// 让文本积累更完整,避免只显示一两个字
|
||||
this.lastUpdateTime = now
|
||||
this.pendingFlushTimer = setTimeout(() => {
|
||||
this.pendingFlushTimer = null
|
||||
void this.flush()
|
||||
}, THROTTLE.BATCH_AFTER_GAP_MS)
|
||||
} else {
|
||||
await this.flush()
|
||||
}
|
||||
} else if (!this.pendingFlushTimer) {
|
||||
// 在节流窗口内 —— 延迟到窗口结束再刷
|
||||
const delay = throttleMs - elapsed
|
||||
this.pendingFlushTimer = setTimeout(() => {
|
||||
this.pendingFlushTimer = null
|
||||
void this.flush()
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 H1→H4 and H2~H6→H5. 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 重新开始。')
|
||||
}
|
||||
|
||||
@ -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/11310(openclaw 2026-03 实测) */
|
||||
export const FEISHU_CARD_TABLE_LIMIT = 3
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: optimizeMarkdownForFeishu
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 对将要放入 `tag: 'markdown'` 元素的内容做安全预处理。
|
||||
* 对将要放入 `tag: 'markdown'` 元素的 markdown 做安全预处理。
|
||||
*
|
||||
* - 标题降级: 若原文包含 H1~H3,则 H2~H6 → H5,H1 → H4
|
||||
* - 代码块内容受保护,不会被降级
|
||||
* - Schema 2.0: 连续标题/表格/代码块前后加 `<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 图片语法: `` */
|
||||
const IMAGE_RE = /!\[([^\]]*)\]\(([^)\s]+)\)/g
|
||||
|
||||
/**
|
||||
* 删除 value 不是飞书 image key (`img_xxx`) 的 markdown 图片引用。
|
||||
* 防止 CardKit 错误 200570(unknown image key)。
|
||||
*
|
||||
* HTTP URL 和本地路径也会被删除 —— 上游(ImageResolver)负责把它们转成
|
||||
* `img_xxx`,此函数是 safety net。
|
||||
*/
|
||||
function stripInvalidImageKeys(text: string): string {
|
||||
if (!text.includes('![')) return text
|
||||
return text.replace(IMAGE_RE, (fullMatch, _alt, value) => {
|
||||
if (value.startsWith('img_')) return fullMatch
|
||||
return ''
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table limiting: sanitizeTextForCard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MarkdownTableMatch = {
|
||||
index: number
|
||||
length: number
|
||||
raw: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描正文里会被飞书卡片**实际渲染**的 markdown 表格位置。
|
||||
*
|
||||
* 代码块内的示例表格不会被飞书解析成卡片表格元素,因此要先排除。
|
||||
* 这份结果供 `sanitizeTextForCard` 判断是否需要降级多余的表格。
|
||||
*/
|
||||
export function findMarkdownTablesOutsideCodeBlocks(text: string): MarkdownTableMatch[] {
|
||||
// 先扫描代码块区间
|
||||
const codeBlockRanges: Array<{ start: number; end: number }> = []
|
||||
const codeBlockRegex = /```[\s\S]*?```/g
|
||||
let cbMatch = codeBlockRegex.exec(text)
|
||||
while (cbMatch != null) {
|
||||
codeBlockRanges.push({
|
||||
start: cbMatch.index,
|
||||
end: cbMatch.index + cbMatch[0].length,
|
||||
})
|
||||
cbMatch = codeBlockRegex.exec(text)
|
||||
}
|
||||
const isInsideCodeBlock = (idx: number): boolean =>
|
||||
codeBlockRanges.some((range) => idx >= range.start && idx < range.end)
|
||||
|
||||
// 扫描表格(header | sep | body...)
|
||||
const tableRegex = /\|.+\|[\r\n]+\|[-:| ]+\|[\s\S]*?(?=\n\n|\n(?!\|)|$)/g
|
||||
const matches: MarkdownTableMatch[] = []
|
||||
let tableMatch = tableRegex.exec(text)
|
||||
while (tableMatch != null) {
|
||||
if (!isInsideCodeBlock(tableMatch.index)) {
|
||||
matches.push({
|
||||
index: tableMatch.index,
|
||||
length: tableMatch[0].length,
|
||||
raw: tableMatch[0],
|
||||
})
|
||||
}
|
||||
tableMatch = tableRegex.exec(text)
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* 对正文里超出 `tableLimit` 张的 markdown 表格降级为代码块,防止触发
|
||||
* 230099/11310。前 `tableLimit` 张保持原样(卡片正常渲染),其余用
|
||||
* 反引号包裹(飞书会当成 code block 而不是表格)。
|
||||
*/
|
||||
export function sanitizeTextForCard(
|
||||
text: string,
|
||||
tableLimit: number = FEISHU_CARD_TABLE_LIMIT,
|
||||
): string {
|
||||
const matches = findMarkdownTablesOutsideCodeBlocks(text)
|
||||
if (matches.length <= tableLimit) return text
|
||||
return wrapTablesBeyondLimit(text, matches, Math.max(tableLimit, 0))
|
||||
}
|
||||
|
||||
function wrapTablesBeyondLimit(
|
||||
text: string,
|
||||
matches: readonly MarkdownTableMatch[],
|
||||
keepCount: number,
|
||||
): string {
|
||||
if (matches.length <= keepCount) return text
|
||||
// 从后往前替换,避免前面的替换导致后面的 offset 错乱
|
||||
let result = text
|
||||
for (let i = matches.length - 1; i >= keepCount; i--) {
|
||||
const { index, length, raw } = matches[i]!
|
||||
const replacement = '```\n' + raw + '\n```'
|
||||
result = result.slice(0, index) + replacement + result.slice(index + length)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
459
adapters/feishu/streaming-card.ts
Normal file
459
adapters/feishu/streaming-card.ts
Normal 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+update(cardId 仍然有效)。 */
|
||||
private cardKitStreamActive = false
|
||||
|
||||
// ---- text state ----
|
||||
private accumulatedText = ''
|
||||
private lastFlushedText = ''
|
||||
|
||||
// ---- flush ----
|
||||
private flushController: FlushController
|
||||
|
||||
constructor(private readonly deps: StreamingCardDeps) {
|
||||
this.flushController = new FlushController(() => this.performFlush())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Public API
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 首次创建卡片(CardKit 主路径;失败则降级到直发 Schema 2.0 卡 + patch)。
|
||||
* 幂等:已创建/正在创建时直接返回。
|
||||
*/
|
||||
async ensureCreated(): Promise<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
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user