Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion src/feishu/__tests__/message-builder.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { buildProgressCard, buildResultCard, buildStreamingCard, buildPipelineCard, buildStatusCard, buildTurnCard, buildToolProgressCard, buildOverviewCard, buildSimpleResultCard } from '../message-builder.js';
import { buildProgressCard, buildResultCard, buildStreamingCard, buildPipelineCard, buildStatusCard, buildTurnCard, buildToolProgressCard, buildTextContentCard, buildOverviewCard, buildSimpleResultCard } from '../message-builder.js';
import type { TurnInfo, ToolCallInfo, ActivityStatus } from '../../claude/types.js';

describe('buildProgressCard', () => {
Expand Down Expand Up @@ -502,3 +502,50 @@ describe('buildToolProgressCard', () => {
expect(body).toContain('_(无工具调用)_');
});
});

describe('buildTextContentCard', () => {
it('should show text with wathet header when in progress', () => {
const card = buildTextContentCard('这是 agent 的输出', 3) as any;
expect(card.header.template).toBe('wathet');
expect(card.header.title.content).toContain('生成中');
const body = card.elements[0].text.content as string;
expect(body).toBe('这是 agent 的输出');
const note = card.elements[2].elements[0].content as string;
expect(note).toContain('⏳ 生成中');
expect(note).toContain('3 轮');
});

it('should show turquoise header when completed', () => {
const card = buildTextContentCard('最终结果', 5, true) as any;
expect(card.header.template).toBe('turquoise');
expect(card.header.title.content).toBe('💬 Agent 输出');
expect(card.header.title.content).not.toContain('生成中');
const note = card.elements[2].elements[0].content as string;
expect(note).not.toContain('⏳');
expect(note).toContain('5 轮');
});

it('should not truncate short text', () => {
const shortText = '短文本内容';
const card = buildTextContentCard(shortText, 1) as any;
const body = card.elements[0].text.content as string;
expect(body).toBe(shortText);
expect(body).not.toContain('已省略');
});

it('should truncate long text keeping tail and adding prefix', () => {
const longText = '前'.repeat(5000) + '后'.repeat(5000);
const card = buildTextContentCard(longText, 2) as any;
const body = card.elements[0].text.content as string;
expect(body).toContain('已省略');
expect(body).toContain('后后后');
const serialized = JSON.stringify(card);
expect(Buffer.byteLength(serialized, 'utf-8')).toBeLessThan(30720);
});

it('should show placeholder for empty text', () => {
const card = buildTextContentCard('', 1) as any;
const body = card.elements[0].text.content as string;
expect(body).toContain('暂无输出');
});
});
52 changes: 48 additions & 4 deletions src/feishu/event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { taskQueue } from '../session/queue.js';
import { claudeExecutor } from '../claude/executor.js';
import { DEFAULT_IMAGE_PROMPT } from '../claude/types.js';
import type { TurnInfo, ToolCallInfo, ImageAttachment } from '../claude/types.js';
import { buildResultCard, buildStatusCard, buildCancelledCard, buildPipelineCard, buildPipelineConfirmCard, buildProgressCard, buildToolProgressCard, buildSimpleResultCard } from './message-builder.js';
import { buildResultCard, buildStatusCard, buildCancelledCard, buildPipelineCard, buildPipelineConfirmCard, buildProgressCard, buildToolProgressCard, buildTextContentCard, buildSimpleResultCard } from './message-builder.js';
import { TOTAL_PHASES } from '../pipeline/types.js';
import { feishuClient, runWithAccountId } from './client.js';
import { config, isMultiBotMode } from '../config.js';
Expand Down Expand Up @@ -1130,16 +1130,48 @@ async function executeClaudeTask(

// 构造逐条 turn 回调
// 策略:缓冲最后一个 turn,收到新 turn 时将前一个 turn 的 tool calls 刷入累积器,
// 原地更新进度卡片。结束时最后一个 turn 合并进结果卡片。
// 原地更新进度卡片。文本内容同步刷入文本卡片。结束时最后一个 turn 合并进结果卡片。
let turnCount = 0;
let pendingTurn: TurnInfo | undefined;
const accumulatedToolCalls: ToolCallInfo[] = [];
let accumulatedText = '';
let textCardMsgId: string | undefined;
let textCardFailed = false;

/** 将文本追加到累积文本 */
const appendText = (text: string) => {
accumulatedText += (accumulatedText ? '\n\n' : '') + text;
};

/** 追加文本(可选)并创建/更新文本卡片 */
const flushTextCard = async (extraText?: string, completed: boolean = false) => {
if (extraText) appendText(extraText);
if (!accumulatedText || !threadReplyMsgId || textCardFailed) return;
try {
if (!textCardMsgId) {
textCardMsgId = await feishuClient.replyCardInThread(
threadReplyMsgId,
buildTextContentCard(accumulatedText, turnCount, completed),
) ?? undefined;
if (!textCardMsgId) textCardFailed = true;
} else {
await feishuClient.updateCard(
textCardMsgId,
buildTextContentCard(accumulatedText, turnCount, completed),
);
}
} catch (err) {
logger.warn({ err }, 'Failed to update text content card');
textCardFailed = true;
}
};

const onTurn = async (turn: TurnInfo) => {
turnCount = turn.turnIndex;
// 将前一个 turn 的 tool calls 刷入累积器,原地更新进度卡片
// 将前一个 turn 的 tool calls 和文本刷入累积器,原地更新进度卡片和文本卡片
if (pendingTurn) {
accumulatedToolCalls.push(...pendingTurn.toolCalls);
if (pendingTurn.textContent) appendText(pendingTurn.textContent);
if (progressCardMsgId && !progressCardFailed) {
try {
await feishuClient.updateCard(
Expand All @@ -1151,6 +1183,7 @@ async function executeClaudeTask(
progressCardFailed = true;
}
}
await flushTextCard();
}
// 缓冲当前 turn
pendingTurn = turn;
Expand Down Expand Up @@ -1313,6 +1346,9 @@ async function executeClaudeTask(
);
}

// 将最后一个 turn 的文本也刷入文本卡片并标记完成
await flushTextCard(pendingTurn?.textContent, true);

await sendResultCard(
prompt, restartResult, totalDurationMs, totalCostUsd,
threadReplyMsgId, chatId, threadReplyMsgId ? pendingTurn : undefined, turnCount,
Expand Down Expand Up @@ -1369,12 +1405,18 @@ async function executeClaudeTask(

// 进度卡片切换为完成态
if (progressCardMsgId) {
const allToolCalls = pendingTurn
? [...accumulatedToolCalls, ...pendingTurn.toolCalls]
: accumulatedToolCalls;
await feishuClient.updateCard(
progressCardMsgId,
buildToolProgressCard(accumulatedToolCalls, turnCount, undefined, true),
buildToolProgressCard(allToolCalls, turnCount, undefined, true),
);
}

// 将最后一个 turn 的文本也刷入文本卡片并标记完成
await flushTextCard(pendingTurn?.textContent, true);

await sendResultCard(
prompt, result, result.durationMs, result.costUsd,
threadReplyMsgId, chatId, threadReplyMsgId ? pendingTurn : undefined, turnCount,
Expand All @@ -1399,6 +1441,8 @@ async function executeClaudeTask(
buildToolProgressCard(allToolCalls, turnCount, undefined, true),
).catch(() => {});
}
// 文本卡片 best-effort 刷新
await flushTextCard(pendingTurn?.textContent, true).catch(() => {});
const errorReply = `❌ 执行出错: ${(err as Error).message}`;
if (threadReplyMsgId) {
await feishuClient.replyTextInThread(threadReplyMsgId, errorReply);
Expand Down
68 changes: 68 additions & 0 deletions src/feishu/message-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,74 @@ export function buildToolProgressCard(
};
}

/**
* 将文本截断到指定 UTF-8 字节上限,保留尾部(最新内容)。
* 超限时从头部截断,保证完整 UTF-8 字符边界。
*/
function truncateToByteLimit(text: string, maxBytes: number): { text: string; truncated: boolean } {
// 快速路径:byteLength 是 O(n) 扫描但不分配 Buffer
if (Buffer.byteLength(text, 'utf-8') <= maxBytes) return { text, truncated: false };

const buf = Buffer.from(text, 'utf-8');
// 从尾部保留 maxBytes,找到合法的 UTF-8 字符起始位置
let start = buf.length - maxBytes;
// UTF-8 continuation bytes: 10xxxxxx (0x80-0xBF), 跳到下一个 leading byte
while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++;
return { text: buf.subarray(start).toString('utf-8'), truncated: true };
}

/** 飞书卡片 content 字节上限(留 2KB 给 JSON 结构开销) */
const CARD_TEXT_MAX_BYTES = 28000;

/** 构建累积文本内容卡片(原地更新,显示 agent 输出文本) */
export function buildTextContentCard(
text: string,
turnCount: number,
completed: boolean = false,
): Record<string, unknown> {
const { text: displayText, truncated } = truncateToByteLimit(text, CARD_TEXT_MAX_BYTES);

const content = truncated
? `_(前部分内容已省略)_\n\n${displayText}`
: displayText;

const headerTitle = completed
? '💬 Agent 输出'
: '💬 Agent 输出 - 生成中';
const headerTemplate = completed ? 'turquoise' : 'wathet';

const footerParts: string[] = [];
if (!completed) footerParts.push('⏳ 生成中');
footerParts.push(`🔄 ${turnCount} 轮`);

return {
config: { wide_screen_mode: true },
header: {
title: { tag: 'plain_text', content: headerTitle },
template: headerTemplate,
},
elements: [
{
tag: 'div',
text: {
tag: 'lark_md',
content: content || '_(暂无输出)_',
},
},
{ tag: 'hr' },
{
tag: 'note',
elements: [
{
tag: 'plain_text',
content: footerParts.join(' | '),
},
],
},
],
};
}

/** 构建单轮 turn 消息卡片(逐条展示) */
export function buildTurnCard(turn: TurnInfo): Record<string, unknown> {
const parts: string[] = [];
Expand Down