From 54eb77d7c75dddab3ef9bfd7af1f623a145888c3 Mon Sep 17 00:00:00 2001
From: mateaix <7333791@qq.com>
Date: Sun, 9 Aug 2026 20:34:54 +0800
Subject: [PATCH] feat(feishu): show streaming execution progress
---
.../channel/feishu/FeishuChannelAdapter.java | 110 +++++++-
.../feishu/FeishuProgressRenderer.java | 260 ++++++++++++++++++
.../feishu/FeishuStreamingCardManager.java | 160 ++++++++---
.../src/main/resources/docs/en/channels.md | 7 +-
.../src/main/resources/docs/zh/channels.md | 7 +-
.../feishu/FeishuProcessStreamTest.java | 216 +++++++++++++++
.../FeishuStreamingCardManagerTest.java | 77 +++++-
.../components/channels/ChannelEditModal.vue | 3 +
mateclaw-ui/src/i18n/locales/en-US.ts | 2 +
mateclaw-ui/src/i18n/locales/zh-CN.ts | 2 +
mateclaw-ui/src/types/index.ts | 2 +
11 files changed, 795 insertions(+), 51 deletions(-)
create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuProgressRenderer.java
create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuProcessStreamTest.java
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java
index f82f9c34..045ea059 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java
@@ -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
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuProgressRenderer.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuProgressRenderer.java
new file mode 100644
index 00000000..b4c30dc6
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuProgressRenderer.java
@@ -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.
+ *
+ *
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 toolLines = new ArrayDeque<>();
+ private final Deque 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 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 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 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);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuStreamingCardManager.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuStreamingCardManager.java
index 1b883057..9a406876 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuStreamingCardManager.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuStreamingCardManager.java
@@ -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.
*
* 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.
+ *
+ *
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 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());
}
}
diff --git a/mateclaw-server/src/main/resources/docs/en/channels.md b/mateclaw-server/src/main/resources/docs/en/channels.md
index a6d77e31..75d2c40c 100644
--- a/mateclaw-server/src/main/resources/docs/en/channels.md
+++ b/mateclaw-server/src/main/resources/docs/en/channels.md
@@ -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
diff --git a/mateclaw-server/src/main/resources/docs/zh/channels.md b/mateclaw-server/src/main/resources/docs/zh/channels.md
index 86b6edbe..19faa59d 100644
--- a/mateclaw-server/src/main/resources/docs/zh/channels.md
+++ b/mateclaw-server/src/main/resources/docs/zh/channels.md
@@ -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
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuProcessStreamTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuProcessStreamTest.java
new file mode 100644
index 00000000..f8b2e263
--- /dev/null
+++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuProcessStreamTest.java
@@ -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 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 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 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 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 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 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 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) {}
+}
diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuStreamingCardManagerTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuStreamingCardManagerTest.java
index ee8d206e..b8a7bc98 100644
--- a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuStreamingCardManagerTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuStreamingCardManagerTest.java
@@ -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;
* Behaviour pinned:
*
* - throttle window suppresses sub-window flushes; forceFlush
- * bypasses it; finishCard always flushes
+ * bypasses the UX throttle but retains the platform hard limit
* - session is removed from {@code activeSessions} on terminal
* transition; subsequent appends are no-ops
* - 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 contentCalls = new java.util.concurrent.CopyOnWriteArrayList<>();
final List closeCalls = new java.util.concurrent.CopyOnWriteArrayList<>();
final AtomicLong fakeNowMs = new AtomicLong(0);
final AtomicReference nextCardId = new AtomicReference<>("card_abc");
final AtomicReference 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() {
diff --git a/mateclaw-ui/src/components/channels/ChannelEditModal.vue b/mateclaw-ui/src/components/channels/ChannelEditModal.vue
index 4ebff9f1..1ba35c29 100644
--- a/mateclaw-ui/src/components/channels/ChannelEditModal.vue
+++ b/mateclaw-ui/src/components/channels/ChannelEditModal.vue
@@ -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') })
}
diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts
index 6de599a8..7c31577b 100644
--- a/mateclaw-ui/src/i18n/locales/en-US.ts
+++ b/mateclaw-ui/src/i18n/locales/en-US.ts
@@ -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',
diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts
index cd35d090..75678c1e 100644
--- a/mateclaw-ui/src/i18n/locales/zh-CN.ts
+++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts
@@ -3881,6 +3881,8 @@ export default {
messageReason: '基础:收发消息',
receive: '接收消息事件',
receiveReason: '基础:接收用户消息',
+ cardkit: '创建与更新卡片',
+ cardkitReason: '流式卡片:实时展示执行轨迹与回答',
resource: '获取消息中的资源文件',
resourceReason: 'WebSocket 模式下获取消息内容',
reactions: '管理消息表情回复',
diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts
index be5d18ed..c9cd1abc 100644
--- a/mateclaw-ui/src/types/index.ts
+++ b/mateclaw-ui/src/types/index.ts
@@ -644,6 +644,8 @@ export const CHANNEL_FIELD_DEFS: Record = {
{ 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: '用户引用某条消息回复时,自动拉取被引用消息内容注入到 prompt,agent 才能理解"解释一下"这种缺主语的引用' },
{ 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' },