feat(feishu): show streaming execution progress

This commit is contained in:
mateaix 2026-08-09 20:34:54 +08:00
parent 8dc6c64683
commit 54eb77d7c7
11 changed files with 795 additions and 51 deletions

View File

@ -12,6 +12,7 @@ import vip.mate.channel.ChannelMessage;
import vip.mate.workspace.core.service.ChatUploadLocationResolver;
import vip.mate.channel.ChannelMessageRouter;
import vip.mate.channel.ExponentialBackoff;
import vip.mate.channel.ProvisionalContentTracker;
import vip.mate.channel.StreamingChannelAdapter;
import vip.mate.channel.media.GeneratedFileScrubber;
import vip.mate.channel.media.MediaSource;
@ -67,6 +68,10 @@ import java.util.concurrent.TimeUnit;
* - card_format: 卡片格式化模式 "auto"默认| "always" | "never"
* auto: 根据内容自动检测always: 全部包卡片never: 全部纯文本降级/调试用
* - card_header: Markdown 卡片 header 文案默认 "AI 助手"设为空串可隐藏 header
* - card_streaming_enabled: 是否启用 CardKit 流式卡片默认 true
* - stream_progress: 是否在流式卡片中展示执行轨迹默认 true
* - filter_thinking: 是否隐藏原始思考文本默认 true状态与阶段轨迹仍展示
* - filter_tool_messages: 是否隐藏工具名称与逐项状态默认 true仍展示汇总数量
* - require_mention: 群聊中是否需要 @机器人 才响应默认 false
* true: 仅当消息中 @了机器人才处理通过飞书 mentions 字段精确判断无需配置 botPrefix
*
@ -2596,29 +2601,70 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
}
StringBuilder accumulator = new StringBuilder();
boolean progressEnabled = getConfigBoolean("stream_progress", true);
FeishuProgressRenderer progress = progressEnabled
? new FeishuProgressRenderer(
System.currentTimeMillis(),
!getConfigBoolean("filter_thinking", true),
!getConfigBoolean("filter_tool_messages", true))
: null;
ProvisionalContentTracker narrationTracker = progressEnabled
? new ProvisionalContentTracker("feishu") : null;
try {
stream.doOnNext(delta -> {
// segmentOnly narration is skipped: appending every
// ReAct iteration's "我来查一下…" into the card text is
// what makes the answer read as if it were sent twice.
if (StreamingChannelAdapter.contributesToFinalContent(delta)) {
accumulator.append(delta.content());
streamingCardManager.appendContent(sessionKey, delta.content(), false);
if (!progressEnabled) {
// Legacy answer-only card mode.
if (StreamingChannelAdapter.contributesToFinalContent(delta)) {
accumulator.append(delta.content());
streamingCardManager.appendContent(sessionKey, delta.content(), false);
}
return;
}
boolean forceFlush = false;
if (delta.isEvent()) {
if ("tool_call_completed".equals(delta.eventType())) {
narrationTracker.onToolObservation();
}
forceFlush = progress.onEvent(delta.eventType(), delta.eventData());
} else if (delta.segmentOnly()) {
String narration = delta.content() != null ? delta.content().trim() : "";
if (!narration.isEmpty()) {
String publishable = narrationTracker.stageNarration(narration, delta.kind());
if (publishable != null) progress.commitNarration(publishable);
progress.onPendingNarration(narration);
forceFlush = true;
}
} else {
if (delta.thinking() != null) progress.onThinkingDelta(delta.thinking());
if (delta.content() != null) {
accumulator.append(delta.content());
progress.onContentDelta(delta.content());
}
}
streamingCardManager.updateContent(sessionKey, progress.snapshot(), forceFlush);
})
.doOnError(err -> {
log.error("[feishu-stream] stream error: sessionKey={}, err={}",
sessionKey, err.getMessage());
streamingCardManager.failCard(sessionKey, err.getMessage());
})
.blockLast(Duration.ofMinutes(5));
String finalContent = accumulator.toString();
// Card streaming never touches renderAndSend, so the channel's
// message-filter config has to be applied here otherwise
// filter_thinking / filter_tool_messages are inert on this path.
// Card streaming never touches renderAndSend, so apply the same
// outbound filters before the final card snapshot is assembled.
String cardContent = filterOutboundContent(finalContent);
if (cardContent.isBlank()) {
cardContent = "";
}
if (progressEnabled) {
String heldNarration = narrationTracker.settle(!cardContent.isBlank());
if (heldNarration != null && !sameOutboundText(heldNarration, cardContent)) {
progress.commitNarration(heldNarration);
}
progress.clearPendingNarration();
cardContent = progress.completedSnapshot(cardContent);
} else if (cardContent.isBlank()) {
cardContent = "(无回复内容)";
}
// Strip any /api/v1/files/generated/{id} URLs out of the card
@ -2629,15 +2675,34 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
// actual file. Cache-miss URLs fall back to the user-facing
// retry hint that GeneratedFileScrubber emits.
String renderedContent = scrubAndSendAttachments(receiveId, cardContent);
streamingCardManager.finishCard(sessionKey, renderedContent);
FeishuStreamingCardManager.FinishResult finishResult =
streamingCardManager.finishCard(sessionKey, renderedContent);
if (!finishResult.success()) {
// The card was delivered but either its terminal content or
// streaming-mode close was rejected. A regular message is the
// only reliable fallback after both CardKit attempts fail.
log.warn("[feishu-stream] Card finalization incomplete (contentUpdated={}, closed={}); "
+ "falling back to regular message: sessionKey={}",
finishResult.finalContentUpdated(), finishResult.streamingClosed(), sessionKey);
sendMessage(receiveId, renderedContent);
}
if (!finishResult.streamingClosed()) {
log.warn("[feishu-stream] Card streaming mode could not be closed after retry: sessionKey={}",
sessionKey);
}
log.info("[feishu-stream] Card streaming completed: sessionKey={}, contentLen={}",
sessionKey, renderedContent.length());
return finalContent.isBlank() ? cardContent : finalContent;
// Execution-trace text is channel presentation only. Never return
// it to the router as assistant content or it will pollute the
// next turn's LLM history. Preserve the legacy empty placeholder
// only when progress rendering was explicitly disabled.
return progressEnabled
? finalContent
: (finalContent.isBlank() ? cardContent : finalContent);
} catch (Exception e) {
log.error("[feishu-stream] Card streaming failed: sessionKey={}, err={}",
sessionKey, e.getMessage(), e);
streamingCardManager.failCard(sessionKey, e.getMessage());
// Tag returned content with the "[错误] " prefix so
// ChannelMessageRouter.isErrorReply flips status='error' on the
@ -2647,6 +2712,17 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
// as a valid assistant turn and re-trigger the same 400.
String partial = accumulator.toString();
String errorPrefix = "[错误] Feishu CardKit streaming failed: " + e.getMessage();
FeishuStreamingCardManager.FinishResult failureResult =
streamingCardManager.failCard(sessionKey, e.getMessage());
if (!failureResult.success()) {
String fallbackError = partial.isBlank()
? "⚠️ 处理失败:" + e.getMessage()
: partial + "\n\n⚠ 处理失败:" + e.getMessage();
log.warn("[feishu-stream] Error card finalization incomplete; sending regular fallback: "
+ "sessionKey={}, contentUpdated={}, closed={}",
sessionKey, failureResult.finalContentUpdated(), failureResult.streamingClosed());
sendMessage(receiveId, fallbackError);
}
if (!partial.isBlank()) {
return errorPrefix + "\n\n已生成的部分内容已忽略\n" + partial;
}
@ -2654,6 +2730,14 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
}
}
/** Compare text after the same outbound filters the receiver sees. */
private boolean sameOutboundText(String a, String b) {
if (a == null || b == null) return false;
String left = filterOutboundContent(a).trim();
String right = filterOutboundContent(b).trim();
return !left.isEmpty() && left.equals(right);
}
/**
* Streaming fallback accumulate all deltas, then send through the
* existing {@link #sendMessage} path so the message goes out as a

View File

@ -0,0 +1,260 @@
package vip.mate.channel.feishu;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Map;
/**
* Builds the execution trace rendered inside a Feishu CardKit streaming card.
*
* <p>The renderer deliberately separates user-visible progress from persisted
* assistant content. The adapter returns only the final answer to the router,
* while this class keeps a bounded live trace in the card: phase, plan step,
* tool transitions, optional model thinking, and grounded stage narration.
*/
final class FeishuProgressRenderer {
private static final int MAX_TOOL_LINES = 3;
private static final int MAX_NARRATION_LINES = 3;
private static final int THINKING_WINDOW = 500;
private static final int ANSWER_WINDOW = 1200;
private record ToolLine(String callId, String name, long startedAt,
Long finishedAt, boolean success) {}
private final long startedAtMillis;
private final boolean showThinking;
private final boolean showToolTrace;
private final Deque<ToolLine> toolLines = new ArrayDeque<>();
private final Deque<String> committedNarrations = new ArrayDeque<>();
private final StringBuilder thinkingTail = new StringBuilder();
private final StringBuilder answerTail = new StringBuilder();
private int collapsedToolCount;
private boolean thinkingSeen;
private boolean contentSeen;
private boolean approvalPending;
private String planStepLine;
private String pendingNarration;
FeishuProgressRenderer(long startedAtMillis, boolean showThinking, boolean showToolTrace) {
this.startedAtMillis = startedAtMillis;
this.showThinking = showThinking;
this.showToolTrace = showToolTrace;
}
void onThinkingDelta(String delta) {
thinkingSeen = true;
if (showThinking && delta != null && !delta.isEmpty()) {
thinkingTail.append(delta);
trimLeading(thinkingTail, THINKING_WINDOW);
}
}
void onContentDelta(String delta) {
contentSeen = true;
if (delta != null && !delta.isEmpty()) {
answerTail.append(delta);
trimLeading(answerTail, ANSWER_WINDOW);
}
}
/** Returns true for transitions that should bypass the normal update throttle. */
boolean onEvent(String eventType, Map<String, Object> data) {
if (eventType == null) return false;
switch (eventType) {
case "tool_call_started" -> {
toolLines.addLast(new ToolLine(
stringField(data, "toolCallId"),
stringField(data, "toolName"),
System.currentTimeMillis(), null, false));
compactToolLines();
return true;
}
case "tool_call_completed" -> {
String callId = stringField(data, "toolCallId");
boolean success = data == null || !Boolean.FALSE.equals(data.get("success"));
markToolCompleted(callId, stringField(data, "toolName"), success);
return true;
}
case "plan_step_started" -> {
Object index = data != null ? data.get("index") : null;
String title = stringField(data, "title");
planStepLine = "📋 步骤" + (index != null ? " " + index : "")
+ (title != null && !title.isBlank() ? "" + title : "");
return true;
}
case "tool_approval_requested" -> {
approvalPending = true;
return true;
}
default -> {
return false;
}
}
}
void onPendingNarration(String text) {
pendingNarration = normalize(text);
}
void commitNarration(String text) {
String normalized = normalize(text);
if (normalized == null) return;
committedNarrations.addLast(normalized);
while (committedNarrations.size() > MAX_NARRATION_LINES) {
committedNarrations.removeFirst();
}
}
void clearPendingNarration() {
pendingNarration = null;
}
boolean isApprovalPending() {
return approvalPending;
}
String snapshot() {
StringBuilder sb = new StringBuilder();
appendTrace(sb, statusLine(false), true);
if (answerTail.length() > 0) {
sb.append("\n\n---\n\n").append(answerTail);
}
return sb.toString();
}
String completedSnapshot(String finalAnswer) {
String answer = finalAnswer == null ? "" : finalAnswer.trim();
StringBuilder sb = new StringBuilder();
appendTrace(sb, statusLine(true), false);
if (!answer.isEmpty()) {
sb.append("\n\n---\n\n").append(answer);
} else if (approvalPending) {
sb.append("\n\n⏸ 已暂停,等待工具审批。");
} else {
sb.append("\n\n本轮没有产生回复内容");
}
return sb.toString();
}
private void appendTrace(StringBuilder sb, String status, boolean includePending) {
sb.append("**执行轨迹**\n").append(status);
if (planStepLine != null) sb.append('\n').append(planStepLine);
appendToolLines(sb);
for (String narration : committedNarrations) {
sb.append("\n• ").append(narration);
}
if (includePending && pendingNarration != null) {
sb.append("\n• ").append(pendingNarration);
}
if (showThinking && thinkingTail.length() > 0) {
sb.append("\n\n> 💭 ")
.append(thinkingTail.toString().replace("\n", "\n> "));
}
}
private String statusLine(boolean completed) {
if (completed) return approvalPending ? "⏸️ 等待工具审批(" + elapsed() + ""
: "✅ 已完成(" + elapsed() + "";
if (approvalPending) return "⏸️ 等待工具审批…(" + elapsed() + "";
if (contentSeen) return "✍️ 正在回复…(" + elapsed() + "";
ToolLine running = lastRunningTool();
if (running != null) {
return showToolTrace
? "🔧 正在调用 " + displayName(running) + "…(" + elapsed() + ""
: "🔧 正在执行工具…(" + elapsed() + "";
}
return (thinkingSeen ? "💭" : "🤔") + " 思考中…(" + elapsed() + "";
}
private void appendToolLines(StringBuilder sb) {
if (!showToolTrace) {
int completed = collapsedToolCount;
boolean running = false;
for (ToolLine line : toolLines) {
if (line.finishedAt() == null) running = true;
else completed++;
}
if (completed > 0) sb.append("\n✅ 已执行 ").append(completed).append(" 项工具");
if (running && contentSeen) sb.append("\n🔧 工具运行中…");
return;
}
if (collapsedToolCount > 0) sb.append("\n…等 ").append(collapsedToolCount).append(" 项已完成");
for (ToolLine line : toolLines) {
if (line.finishedAt() == null) {
if (contentSeen || approvalPending) sb.append("\n🔧 ").append(displayName(line)).append(" 运行中…");
} else {
long seconds = Math.max(0, (line.finishedAt() - line.startedAt()) / 1000);
sb.append('\n').append(line.success() ? "" : "")
.append(displayName(line))
.append(line.success() ? " 完成" : " 失败")
.append("").append(seconds).append(" 秒)");
}
}
}
private ToolLine lastRunningTool() {
ToolLine running = null;
for (ToolLine line : toolLines) if (line.finishedAt() == null) running = line;
return running;
}
private void markToolCompleted(String callId, String toolName, boolean success) {
ToolLine match = null;
for (ToolLine line : toolLines) {
if (line.finishedAt() != null) continue;
if ((callId != null && callId.equals(line.callId()))
|| (callId == null && toolName != null && toolName.equals(line.name()))) {
match = line;
}
}
long now = System.currentTimeMillis();
if (match == null) {
toolLines.addLast(new ToolLine(callId, toolName, now, now, success));
} else {
Deque<ToolLine> rebuilt = new ArrayDeque<>(toolLines.size());
for (ToolLine line : toolLines) {
rebuilt.addLast(line == match
? new ToolLine(match.callId(), match.name(), match.startedAt(), now, success)
: line);
}
toolLines.clear();
toolLines.addAll(rebuilt);
}
compactToolLines();
}
private void compactToolLines() {
while (toolLines.size() > MAX_TOOL_LINES) {
ToolLine oldest = toolLines.peekFirst();
if (oldest != null && oldest.finishedAt() == null) break;
toolLines.pollFirst();
collapsedToolCount++;
}
}
private String elapsed() {
long seconds = Math.max(0, (System.currentTimeMillis() - startedAtMillis) / 1000);
return seconds < 60 ? "" + seconds + ""
: "" + (seconds / 60) + "" + (seconds % 60) + "";
}
private static String displayName(ToolLine line) {
return line.name() != null && !line.name().isBlank() ? line.name() : "工具";
}
private static String stringField(Map<String, Object> data, String key) {
Object value = data != null ? data.get(key) : null;
return value != null ? value.toString() : null;
}
private static String normalize(String text) {
return text == null || text.isBlank() ? null : text.trim();
}
private static void trimLeading(StringBuilder sb, int maxLen) {
int excess = sb.length() - maxLen;
if (excess > 0) sb.delete(0, excess);
}
}

View File

@ -64,6 +64,13 @@ public class FeishuStreamingCardManager {
/** Throttle window for {@link #appendContent}, ms — matches DingTalk AICard. */
static final long THROTTLE_INTERVAL_MS = 500;
/**
* Hard per-card operation spacing. Feishu allows at most 10 CardKit
* operations/second for one card; 120ms leaves a little clock/network
* jitter headroom while still letting phase transitions feel immediate.
*/
static final long PLATFORM_MIN_INTERVAL_MS = 120;
/**
* Markdown element id baked into the initial streaming card.
* Content-update calls reference this id. Public so tests can assert.
@ -91,6 +98,13 @@ public class FeishuStreamingCardManager {
/** Terminal-state CAS guard — at most one of {finishCard, failCard} wins per session. */
enum Status { STREAMING, FINISHED, FAILED }
/** Result of the two independently fallible terminal CardKit operations. */
public record FinishResult(boolean finalContentUpdated, boolean streamingClosed) {
public boolean success() {
return finalContentUpdated && streamingClosed;
}
}
/**
* One in-flight streaming card. State is mutated by a single Reactor
* thread per session (the one consuming the {@code Flux}), so all
@ -178,8 +192,8 @@ public class FeishuStreamingCardManager {
}
/**
* Append delta text to the running session. May flush immediately
* (force) or wait for the next throttle window.
* Append delta text to the running session. A forced update bypasses the
* normal 500ms UX throttle but still respects the platform hard limit.
*
* <p>No-op when {@code sessionKey} is unknown or the session has
* already reached a terminal status keeps the caller's
@ -195,11 +209,22 @@ public class FeishuStreamingCardManager {
session.accumulated.append(contentDelta);
}
}
long now = currentTimeMs();
if (!forceFlush && now - session.lastFlushMs < THROTTLE_INTERVAL_MS) {
return;
}
flush(session, now);
flushWithPolicy(session, forceFlush);
}
/**
* Replace the streaming element with a full progress snapshot.
*
* <p>CardKit's content API expects the complete current text on every
* update. Agent progress is not append-only ("thinking" becomes "calling
* a tool", then "replying"), so treating snapshots as deltas duplicates
* the entire trace on every refresh.
*/
public boolean updateContent(String sessionKey, String fullContent, boolean forceFlush) {
CardSession session = activeSessions.get(sessionKey);
if (session == null || !session.isStreaming()) return false;
replaceAccumulated(session, fullContent != null ? fullContent : "");
return flushWithPolicy(session, forceFlush);
}
/**
@ -207,20 +232,24 @@ public class FeishuStreamingCardManager {
* a second call is a no-op. After return, the sessionKey is no
* longer known to the manager.
*/
public void finishCard(String sessionKey, String finalContent) {
public FinishResult finishCard(String sessionKey, String finalContent) {
CardSession session = activeSessions.get(sessionKey);
if (session == null) return;
if (session == null) return new FinishResult(false, false);
if (!session.status.compareAndSet(Status.STREAMING, Status.FINISHED)) {
return;
return new FinishResult(false, false);
}
boolean contentUpdated = false;
boolean streamingClosed = false;
try {
replaceAccumulated(session, finalContent != null ? finalContent : "");
flush(session, currentTimeMs());
closeStreaming(session);
contentUpdated = flushWithRetry(session);
streamingClosed = closeStreamingWithRetry(session, summaryFor(finalContent));
return new FinishResult(contentUpdated, streamingClosed);
} finally {
activeSessions.remove(sessionKey);
log.info("[feishu-stream] Card finished: sessionKey={}, contentLen={}",
sessionKey, finalContent == null ? 0 : finalContent.length());
log.info("[feishu-stream] Card finished: sessionKey={}, contentLen={}, contentUpdated={}, closed={}",
sessionKey, finalContent == null ? 0 : finalContent.length(),
contentUpdated, streamingClosed);
}
}
@ -229,12 +258,14 @@ public class FeishuStreamingCardManager {
* suffix; the card is closed so the typing animation stops.
* Idempotent.
*/
public void failCard(String sessionKey, String errorMessage) {
public FinishResult failCard(String sessionKey, String errorMessage) {
CardSession session = activeSessions.get(sessionKey);
if (session == null) return;
if (session == null) return new FinishResult(false, false);
if (!session.status.compareAndSet(Status.STREAMING, Status.FAILED)) {
return;
return new FinishResult(false, false);
}
boolean contentUpdated = false;
boolean streamingClosed = false;
try {
String tail;
synchronized (session) {
@ -246,11 +277,13 @@ public class FeishuStreamingCardManager {
session.accumulated.setLength(0);
session.accumulated.append(tail);
}
flush(session, currentTimeMs());
closeStreaming(session);
contentUpdated = flushWithRetry(session);
streamingClosed = closeStreamingWithRetry(session, "⚠️ 处理失败");
return new FinishResult(contentUpdated, streamingClosed);
} finally {
activeSessions.remove(sessionKey);
log.warn("[feishu-stream] Card failed: sessionKey={}, error={}", sessionKey, errorMessage);
log.warn("[feishu-stream] Card failed: sessionKey={}, contentUpdated={}, closed={}, error={}",
sessionKey, contentUpdated, streamingClosed, errorMessage);
}
}
@ -272,7 +305,26 @@ public class FeishuStreamingCardManager {
// Internal flush + SDK seams
// ------------------------------------------------------------------
private void flush(CardSession session, long now) {
private boolean flushWithPolicy(CardSession session, boolean forceFlush) {
long now = currentTimeMs();
long elapsed = now - session.lastFlushMs;
if (!forceFlush && elapsed < THROTTLE_INTERVAL_MS) {
return true; // latest snapshot is queued in session.accumulated
}
if (forceFlush && elapsed < PLATFORM_MIN_INTERVAL_MS) {
if (!pauseBeforeFlush(PLATFORM_MIN_INTERVAL_MS - elapsed)) return false;
now = currentTimeMs();
}
return flush(session, now);
}
/** One retry is enough to cover a transient rate-limit/network blip. */
private boolean flushWithRetry(CardSession session) {
if (flushWithPolicy(session, true)) return true;
return flushWithPolicy(session, true);
}
private boolean flush(CardSession session, long now) {
String snapshot;
synchronized (session) {
snapshot = session.accumulated.toString();
@ -281,27 +333,45 @@ public class FeishuStreamingCardManager {
try {
Client client = clientFactory.client(session.channelId);
sdkPushElementContent(client, session.cardId, STREAM_ELEMENT_ID, snapshot, seq);
session.lastFlushMs = now;
return true;
} catch (Exception e) {
log.warn("[feishu-stream] flush failed: sessionKey={}, seq={}, err={}",
session.sessionKey, seq, e.getMessage());
return false;
} finally {
// Failed requests count against platform rate limits too.
session.lastFlushMs = now;
}
}
private void closeStreaming(CardSession session) {
private boolean closeStreamingWithRetry(CardSession session, String summary) {
if (closeStreaming(session, summary)) return true;
return closeStreaming(session, summary);
}
private boolean closeStreaming(CardSession session, String summary) {
long elapsed = currentTimeMs() - session.lastFlushMs;
if (elapsed < PLATFORM_MIN_INTERVAL_MS
&& !pauseBeforeFlush(PLATFORM_MIN_INTERVAL_MS - elapsed)) {
return false;
}
int seq = session.sequence.incrementAndGet();
try {
Client client = clientFactory.client(session.channelId);
sdkCloseStreamingMode(client, session.cardId, seq);
sdkCloseStreamingMode(client, session.cardId, seq, summary);
return true;
} catch (Exception e) {
log.warn("[feishu-stream] closeStreaming failed: sessionKey={}, err={}",
session.sessionKey, e.getMessage());
return false;
} finally {
session.lastFlushMs = currentTimeMs();
}
}
private void tryCloseStreamingSilently(Client client, String cardId) {
try {
sdkCloseStreamingMode(client, cardId, 1);
sdkCloseStreamingMode(client, cardId, 1, "⚠️ 卡片发送失败");
} catch (Exception ignore) {
// best-effort already in an error path
}
@ -314,6 +384,31 @@ public class FeishuStreamingCardManager {
}
}
private boolean pauseBeforeFlush(long millis) {
if (millis <= 0) return true;
try {
sleepMillis(millis);
return true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
/** Test seam for advancing a fake clock without real sleeping. */
protected void sleepMillis(long millis) throws InterruptedException {
Thread.sleep(millis);
}
static String summaryFor(String content) {
String preview = content == null ? "" : content
.replaceAll("[`*_>#~-]+", " ")
.replaceAll("\\s+", " ")
.trim();
if (preview.isEmpty()) return "✅ 已完成";
return preview.length() <= 80 ? preview : preview.substring(0, 77) + "...";
}
// ------------------------------------------------------------------
// SDK seams (overridable in tests)
// ------------------------------------------------------------------
@ -373,15 +468,18 @@ public class FeishuStreamingCardManager {
.build();
ContentCardElementResp resp = client.cardkit().v1().cardElement().content(req);
if (!resp.success()) {
log.warn("[feishu-stream] cardElement.content failed: cardId={}, seq={}, code={}, msg={}",
abbrev(cardId), sequence, resp.getCode(), resp.getMsg());
throw new IllegalStateException("cardElement.content failed: cardId=" + abbrev(cardId)
+ ", seq=" + sequence + ", code=" + resp.getCode() + ", msg=" + resp.getMsg());
}
}
/** Flip streaming_mode=false so the receiving UI stops the typing animation. */
protected void sdkCloseStreamingMode(Client client, String cardId, int sequence) throws Exception {
protected void sdkCloseStreamingMode(Client client, String cardId, int sequence,
String summary) throws Exception {
Map<String, Object> settings = Map.of(
"config", Map.of("streaming_mode", false)
"config", Map.of(
"streaming_mode", false,
"summary", Map.of("content", summaryFor(summary)))
);
SettingsCardReq req = SettingsCardReq.newBuilder()
.cardId(cardId)
@ -393,8 +491,8 @@ public class FeishuStreamingCardManager {
.build();
SettingsCardResp resp = client.cardkit().v1().card().settings(req);
if (!resp.success()) {
log.warn("[feishu-stream] card.settings (close) failed: cardId={}, code={}, msg={}",
abbrev(cardId), resp.getCode(), resp.getMsg());
throw new IllegalStateException("card.settings failed: cardId=" + abbrev(cardId)
+ ", code=" + resp.getCode() + ", msg=" + resp.getMsg());
}
}

View File

@ -295,8 +295,12 @@ Tool-guard approval flows arrive as a card with **Approve / Deny** buttons. Tapp
Replies stream char-by-char into a **single card** instead of waiting for the whole answer before sending.
- `card_streaming_enabled` (default `true`)
- The first token appears immediately; subsequent updates are throttled at 500ms
- The first token appears immediately; regular text updates coalesce at 500ms, while phase transitions refresh preferentially behind a 120ms platform safety limit
- `stream_progress` (default `true`) keeps thinking status, plan steps, tool progress, and stage narration in the same card; completion retains a bounded execution trace above the final answer
- Set `filter_thinking=false` to show raw model thinking; by default only status and stage progress are shown
- Set `filter_tool_messages=false` to show tool names and per-tool results; by default only the tool count is shown
- On CardKit failure it falls back to accumulate-then-send
- Final update and close operations retry once; if they still fail, a regular Feishu message carries the answer
#### Inbound voice transcription
@ -354,6 +358,7 @@ curl -X POST http://localhost:18088/api/v1/channels \
"card_format": "auto",
"card_header": "AI 助手",
"card_streaming_enabled": true,
"stream_progress": true,
"media_download_enabled": true,
"enable_done_reaction": true,
"require_mention": false

View File

@ -295,8 +295,12 @@ JSON 卡片 payload 上限约 32 KB超出后自动降级为纯文本。
回复逐字刷新进**同一张卡片**,而不是等整段生成完再发。
- `card_streaming_enabled`(默认 `true`
- 首 token 立即出现,之后按 500ms 节流刷新
- 首 token 立即出现;普通文本按 500ms 合并刷新,阶段切换遵守 120ms 平台硬限流后优先刷新
- `stream_progress`(默认 `true`):同一卡片会展示思考状态、计划步骤、工具进度和阶段旁白,完成后保留一份有界执行轨迹并追加最终回答
- `filter_thinking=false` 时展示模型原始思考文本;默认仅展示状态与阶段轨迹,不暴露原始思考
- `filter_tool_messages=false` 时展示工具名称和逐项结果;默认只展示工具执行数量
- CardKit 调用失败时自动回退到"先攒齐再一次性发出"
- 最终更新和关闭操作会自动重试一次;仍失败则通过普通飞书消息兜底
#### 入站语音转写
@ -354,6 +358,7 @@ curl -X POST http://localhost:18088/api/v1/channels \
"card_format": "auto",
"card_header": "AI 助手",
"card_streaming_enabled": true,
"stream_progress": true,
"media_download_enabled": true,
"enable_done_reaction": true,
"require_mention": false

View File

@ -0,0 +1,216 @@
package vip.mate.channel.feishu;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lark.oapi.Client;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import vip.mate.agent.AgentService.StreamDelta;
import vip.mate.agent.ContentKind;
import vip.mate.channel.ChannelMessage;
import vip.mate.channel.ChannelMessageRouter;
import vip.mate.channel.model.ChannelEntity;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/** End-to-end stream rendering at the Feishu adapter/CardKit boundary. */
class FeishuProcessStreamTest {
private static final class RecordingManager extends FeishuStreamingCardManager {
final List<String> snapshots = new CopyOnWriteArrayList<>();
volatile boolean failCompletedSnapshots;
volatile boolean failErrorSnapshots;
volatile boolean failClose;
RecordingManager(FeishuClientFactory factory, ObjectMapper mapper) {
super(factory, mapper);
}
@Override protected String sdkCreateCard(Client client, String initialText) { return "card_1"; }
@Override protected String sdkSendInteractiveMessage(Client client, String receiveIdType,
String receiveId, String cardId) { return "msg_1"; }
@Override protected void sdkPushElementContent(Client client, String cardId, String elementId,
String content, int sequence) {
if (failCompletedSnapshots && content.contains("✅ 已完成")) {
throw new IllegalStateException("simulated final update failure");
}
if (failErrorSnapshots && content.contains("⚠️")) {
throw new IllegalStateException("simulated error update failure");
}
snapshots.add(content);
}
@Override protected void sdkCloseStreamingMode(Client client, String cardId, int sequence,
String summary) {
if (failClose) throw new IllegalStateException("simulated close failure");
}
@Override protected void sleepMillis(long millis) {}
}
private static final class RecordingAdapter extends FeishuChannelAdapter {
final List<String> fallbackMessages = new CopyOnWriteArrayList<>();
RecordingAdapter(ChannelEntity entity, ObjectMapper mapper, RecordingManager manager) {
super(entity, mock(ChannelMessageRouter.class), mapper, null, null, manager);
}
@Override public void sendMessage(String targetId, String content) {
fallbackMessages.add(content);
}
}
@Test
@DisplayName("default Feishu card shows a filtered execution trace and final answer")
void defaultTraceShowsGenericToolsWithoutRawThinking() {
Fixture f = fixture("{}");
Flux<StreamDelta> stream = Flux.just(
new StreamDelta(null, "内部推理文本"),
StreamDelta.event("tool_call_started",
Map.of("toolCallId", "c1", "toolName", "get_time")),
StreamDelta.event("tool_call_completed",
Map.of("toolCallId", "c1", "toolName", "get_time", "success", true)),
new StreamDelta("现在是下午三点。", null));
assertEquals("现在是下午三点。", f.adapter.processStream(stream, inbound(), "feishu:test"));
String finalCard = f.manager.snapshots.get(f.manager.snapshots.size() - 1);
assertTrue(finalCard.contains("执行轨迹"));
assertTrue(finalCard.contains("已执行 1 项工具"));
assertTrue(finalCard.contains("现在是下午三点。"));
assertFalse(finalCard.contains("get_time"), "default tool filter must hide tool identity");
assertFalse(finalCard.contains("内部推理文本"), "default thinking filter must hide raw thinking");
}
@Test
@DisplayName("Feishu card honors unfiltered thinking and tool-detail settings")
void unfilteredTraceShowsThinkingAndToolName() {
Fixture f = fixture("{\"filter_thinking\":false,\"filter_tool_messages\":false}");
Flux<StreamDelta> stream = Flux.just(
new StreamDelta(null, "先读取当前时间"),
StreamDelta.event("tool_call_started",
Map.of("toolCallId", "c1", "toolName", "get_time")),
StreamDelta.event("tool_call_completed",
Map.of("toolCallId", "c1", "toolName", "get_time", "success", true)),
new StreamDelta("完成。", null));
f.adapter.processStream(stream, inbound(), "feishu:test");
String finalCard = f.manager.snapshots.get(f.manager.snapshots.size() - 1);
assertTrue(finalCard.contains("先读取当前时间"));
assertTrue(finalCard.contains("get_time"));
assertTrue(finalCard.contains("完成。"));
}
@Test
@DisplayName("pre-tool rehearsal remains live but is removed from the completed Feishu card")
void provisionalNarrationDoesNotBecomePermanent() {
Fixture f = fixture("{}");
Flux<StreamDelta> stream = Flux.just(
StreamDelta.segmentOnly("预测温度是 29 度。", null, ContentKind.PRE_TOOL_NARRATION),
StreamDelta.event("tool_call_started",
Map.of("toolCallId", "c1", "toolName", "query_env")),
StreamDelta.event("tool_call_completed",
Map.of("toolCallId", "c1", "toolName", "query_env", "success", true)),
new StreamDelta("接口没有返回环境数据。", null));
f.adapter.processStream(stream, inbound(), "feishu:test");
assertTrue(f.manager.snapshots.stream().anyMatch(s -> s.contains("预测温度是 29 度")),
"provisional narration should be visible while work is in progress");
String finalCard = f.manager.snapshots.get(f.manager.snapshots.size() - 1);
assertFalse(finalCard.contains("29 度"), "superseded rehearsal must not survive completion");
assertTrue(finalCard.contains("接口没有返回环境数据"));
}
@Test
@DisplayName("execution trace is presentation-only and an empty turn stays empty for persistence")
void emptyTurnDoesNotPersistTrace() {
Fixture f = fixture("{}");
Flux<StreamDelta> stream = Flux.just(
StreamDelta.event("tool_call_started",
Map.of("toolCallId", "c1", "toolName", "approval_tool")),
StreamDelta.event("tool_approval_requested", Map.of("toolCallId", "c1")));
assertEquals("", f.adapter.processStream(stream, inbound(), "feishu:test"),
"the router must never persist the rendered execution trace as assistant content");
String finalCard = f.manager.snapshots.get(f.manager.snapshots.size() - 1);
assertTrue(finalCard.contains("等待工具审批"));
}
@Test
@DisplayName("failed terminal CardKit update falls back to a regular Feishu message")
void failedFinalCardUpdateFallsBackToRegularMessage() {
Fixture f = fixture("{}");
f.manager.failCompletedSnapshots = true;
assertEquals("最终答案", f.adapter.processStream(
Flux.just(new StreamDelta("最终答案", null)), inbound(), "feishu:test"));
assertEquals(1, f.adapter.fallbackMessages.size());
assertTrue(f.adapter.fallbackMessages.get(0).contains("最终答案"));
}
@Test
@DisplayName("failed streaming close also falls back after retry")
void failedStreamingCloseFallsBackToRegularMessage() {
Fixture f = fixture("{}");
f.manager.failClose = true;
f.adapter.processStream(Flux.just(new StreamDelta("最终答案", null)),
inbound(), "feishu:test");
assertEquals(1, f.adapter.fallbackMessages.size());
assertTrue(f.adapter.fallbackMessages.get(0).contains("最终答案"));
}
@Test
@DisplayName("failed error-card update also sends a regular error fallback")
void failedErrorCardUpdateFallsBackToRegularMessage() {
Fixture f = fixture("{}");
f.manager.failErrorSnapshots = true;
Flux<StreamDelta> stream = Flux.concat(
Flux.just(new StreamDelta("部分回答", null)),
Flux.error(new IllegalStateException("upstream failed")));
String result = f.adapter.processStream(stream, inbound(), "feishu:test");
assertTrue(result.startsWith("[错误]"));
assertEquals(1, f.adapter.fallbackMessages.size());
assertTrue(f.adapter.fallbackMessages.get(0).contains("upstream failed"));
}
private static ChannelMessage inbound() {
return ChannelMessage.builder()
.channelType("feishu")
.senderId("ou_user")
.replyToken("oc_chat")
.content("hi")
.build();
}
private static Fixture fixture(String configJson) {
ObjectMapper mapper = new ObjectMapper();
FeishuClientFactory factory = mock(FeishuClientFactory.class);
when(factory.client(anyLong())).thenReturn(mock(Client.class));
when(factory.client(any())).thenReturn(mock(Client.class));
RecordingManager manager = new RecordingManager(factory, mapper);
ChannelEntity entity = new ChannelEntity();
entity.setId(1L);
entity.setChannelType("feishu");
entity.setConfigJson(configJson);
RecordingAdapter adapter = new RecordingAdapter(entity, mapper, manager);
return new Fixture(adapter, manager);
}
private record Fixture(RecordingAdapter adapter, RecordingManager manager) {}
}

View File

@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
@ -31,7 +32,7 @@ import static org.mockito.Mockito.when;
* <p>Behaviour pinned:
* <ul>
* <li>throttle window suppresses sub-window flushes; forceFlush
* bypasses it; finishCard always flushes</li>
* bypasses the UX throttle but retains the platform hard limit</li>
* <li>session is removed from {@code activeSessions} on terminal
* transition; subsequent appends are no-ops</li>
* <li>finish vs fail is a CAS-guarded one-shot second terminal
@ -46,13 +47,15 @@ class FeishuStreamingCardManagerTest {
/** Recording SDK seam — captures each call so the test can replay them. */
private static final class RecordingManager extends FeishuStreamingCardManager {
record ContentCall(String cardId, String elementId, String content, int sequence) {}
record CloseCall(String cardId, int sequence) {}
record CloseCall(String cardId, int sequence, String summary) {}
final List<ContentCall> contentCalls = new java.util.concurrent.CopyOnWriteArrayList<>();
final List<CloseCall> closeCalls = new java.util.concurrent.CopyOnWriteArrayList<>();
final AtomicLong fakeNowMs = new AtomicLong(0);
final AtomicReference<String> nextCardId = new AtomicReference<>("card_abc");
final AtomicReference<String> nextMessageId = new AtomicReference<>("msg_abc");
final AtomicInteger contentFailuresRemaining = new AtomicInteger();
final AtomicInteger closeFailuresRemaining = new AtomicInteger();
RecordingManager(FeishuClientFactory factory, ObjectMapper objectMapper) {
super(factory, objectMapper);
@ -69,11 +72,22 @@ class FeishuStreamingCardManagerTest {
@Override protected void sdkPushElementContent(Client client, String cardId, String elementId,
String content, int sequence) {
if (contentFailuresRemaining.getAndUpdate(n -> Math.max(0, n - 1)) > 0) {
throw new IllegalStateException("simulated content failure");
}
contentCalls.add(new ContentCall(cardId, elementId, content, sequence));
}
@Override protected void sdkCloseStreamingMode(Client client, String cardId, int sequence) {
closeCalls.add(new CloseCall(cardId, sequence));
@Override protected void sdkCloseStreamingMode(Client client, String cardId, int sequence,
String summary) {
if (closeFailuresRemaining.getAndUpdate(n -> Math.max(0, n - 1)) > 0) {
throw new IllegalStateException("simulated close failure");
}
closeCalls.add(new CloseCall(cardId, sequence, summary));
}
@Override protected void sleepMillis(long millis) {
fakeNowMs.addAndGet(millis);
}
}
@ -164,6 +178,22 @@ class FeishuStreamingCardManagerTest {
assertEquals("ab", manager.contentCalls.get(1).content());
}
@Test
@DisplayName("full progress snapshots replace rather than append the previous card text")
void updateContentReplacesSnapshot() {
manager.fakeNowMs.set(0L);
String key = manager.createAndDeliver(7L, "open_id", "ou_abc", null);
manager.updateContent(key, "💭 思考中", true);
manager.fakeNowMs.set(50L);
manager.updateContent(key, "🔧 正在执行工具", true);
assertEquals(2, manager.contentCalls.size());
assertEquals("💭 思考中", manager.contentCalls.get(0).content());
assertEquals("🔧 正在执行工具", manager.contentCalls.get(1).content(),
"status transitions must not duplicate the preceding snapshot");
}
@Test
@DisplayName("finishCard emits final content + close, in monotonic sequence order, then removes session")
void finishCardClosesAndUnregisters() {
@ -173,7 +203,8 @@ class FeishuStreamingCardManagerTest {
manager.appendContent(key, "Hello, ", true); // seq 1
manager.fakeNowMs.set(600L);
manager.appendContent(key, "world", false); // seq 2
manager.finishCard(key, "Hello, world!"); // seq 3 (content) + seq 4 (close)
FeishuStreamingCardManager.FinishResult result =
manager.finishCard(key, "Hello, world!"); // seq 3 (content) + seq 4 (close)
assertEquals(3, manager.contentCalls.size());
assertEquals("Hello, world!", manager.contentCalls.get(2).content());
@ -183,9 +214,45 @@ class FeishuStreamingCardManagerTest {
assertEquals(3, manager.contentCalls.get(2).sequence());
assertEquals(1, manager.closeCalls.size());
assertEquals(4, manager.closeCalls.get(0).sequence());
assertEquals("Hello, world!", manager.closeCalls.get(0).summary());
assertTrue(result.success());
assertEquals(0, manager.activeSessionCount());
}
@Test
@DisplayName("terminal content failure is retried and reported to the adapter")
void finishReportsContentFailureAfterRetry() {
String key = manager.createAndDeliver(7L, "open_id", "ou_abc", null);
manager.contentFailuresRemaining.set(2);
FeishuStreamingCardManager.FinishResult result = manager.finishCard(key, "answer");
assertFalse(result.finalContentUpdated());
assertTrue(result.streamingClosed(), "the card should still leave streaming mode");
assertEquals(0, manager.activeSessionCount());
}
@Test
@DisplayName("close failure is retried and remains observable after both attempts fail")
void finishReportsCloseFailureAfterRetry() {
String key = manager.createAndDeliver(7L, "open_id", "ou_abc", null);
manager.closeFailuresRemaining.set(2);
FeishuStreamingCardManager.FinishResult result = manager.finishCard(key, "answer");
assertTrue(result.finalContentUpdated());
assertFalse(result.streamingClosed());
assertEquals(0, manager.activeSessionCount());
}
@Test
@DisplayName("summary strips markdown, collapses whitespace, and stays within preview limit")
void summaryIsSuitableForChatPreview() {
assertEquals("标题 内容", FeishuStreamingCardManager.summaryFor("## 标题\n\n**内容**"));
assertEquals("✅ 已完成", FeishuStreamingCardManager.summaryFor(" "));
assertEquals(80, FeishuStreamingCardManager.summaryFor("x".repeat(120)).length());
}
@Test
@DisplayName("appendContent after finish is a no-op (no SDK call, no resurrection)")
void appendAfterFinishIsNoop() {

View File

@ -666,6 +666,9 @@ const feishuRequiredPermissions = computed(() => {
{ scope: 'im:message', desc: t('channels.feishu.perm.message'), reason: t('channels.feishu.perm.messageReason') },
{ scope: 'im:message.receive_v1', desc: t('channels.feishu.perm.receive'), reason: t('channels.feishu.perm.receiveReason') },
]
if (channelConfig.value?.card_streaming_enabled !== false) {
perms.push({ scope: 'cardkit:card:write', desc: t('channels.feishu.perm.cardkit'), reason: t('channels.feishu.perm.cardkitReason') })
}
if (channelConfig.value?.connection_mode === 'websocket') {
perms.push({ scope: 'im:resource', desc: t('channels.feishu.perm.resource'), reason: t('channels.feishu.perm.resourceReason') })
}

View File

@ -3781,6 +3781,8 @@ export default {
messageReason: 'Core: send and receive messages',
receive: 'Receive message events',
receiveReason: 'Core: receive user messages',
cardkit: 'Create and update cards',
cardkitReason: 'Streaming cards: show execution trace and answer in real time',
resource: 'Access message resources',
resourceReason: 'Get message content in WebSocket mode',
reactions: 'Manage message reactions',

View File

@ -3881,6 +3881,8 @@ export default {
messageReason: '基础:收发消息',
receive: '接收消息事件',
receiveReason: '基础:接收用户消息',
cardkit: '创建与更新卡片',
cardkitReason: '流式卡片:实时展示执行轨迹与回答',
resource: '获取消息中的资源文件',
resourceReason: 'WebSocket 模式下获取消息内容',
reactions: '管理消息表情回复',

View File

@ -644,6 +644,8 @@ export const CHANNEL_FIELD_DEFS: Record<string, ChannelFieldDef[]> = {
{ key: 'enable_nickname_cache', label: '昵称获取', placeholder: '', type: 'switch', defaultValue: true, tooltip: '通过联系人 API 获取用户真实昵称(需要 contact:user.base:readonly 权限)' },
{ key: 'enable_quoted_context', label: '引用消息上下文', placeholder: '', type: 'switch', defaultValue: true, tooltip: '用户引用某条消息回复时,自动拉取被引用消息内容注入到 promptagent 才能理解"解释一下"这种缺主语的引用' },
{ key: 'media_download_enabled', label: '媒体下载', placeholder: '', type: 'switch', defaultValue: false, tooltip: '下载消息中的图片和文件到本地(保存至 ~/.mateclaw/media/feishu/' },
{ key: 'card_streaming_enabled', label: '流式卡片', placeholder: '', type: 'switch', defaultValue: true, tooltip: '使用 CardKit 实时更新回复;需要 cardkit:card:write 权限' },
{ key: 'stream_progress', label: '执行轨迹', placeholder: '', type: 'switch', defaultValue: true, tooltip: '在流式卡片中展示思考状态、计划步骤、工具进度与阶段旁白;原始思考和工具名称仍受下方过滤开关控制' },
],
telegram: [
{ key: 'bot_token', label: 'Bot Token', placeholder: '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11', required: true, sensitive: true, type: 'password', tooltip: '从 @BotFather 获取的 Bot Token' },