diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java
index 9400ecd8..b140e069 100644
--- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java
+++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelManager.java
@@ -92,6 +92,14 @@ public class ChannelManager {
*/
private final vip.mate.channel.media.GeneratedFileScrubber generatedFileScrubber;
+ /**
+ * Feishu CardKit streaming-card manager. Drives
+ * {@link vip.mate.channel.feishu.FeishuChannelAdapter}'s
+ * {@code processStream} so the receiver sees text appearing
+ * character-by-character instead of waiting for the full reply.
+ */
+ private final vip.mate.channel.feishu.FeishuStreamingCardManager feishuStreamingCardManager;
+
/**
* Distributed leader election. Channels whose adapter reports
* {@link ChannelAdapter#requiresSingleLeader()} are gated on a lease so
@@ -1158,7 +1166,7 @@ public class ChannelManager {
case "web" -> new WebChannelAdapter(channel, messageRouter, objectMapper);
case "dingtalk" -> new DingTalkChannelAdapter(channel, messageRouter, objectMapper, generatedFileCache);
case "feishu" -> new FeishuChannelAdapter(channel, messageRouter, objectMapper,
- feishuMediaUploader, generatedFileScrubber);
+ feishuMediaUploader, generatedFileScrubber, feishuStreamingCardManager);
case "telegram" -> new TelegramChannelAdapter(channel, messageRouter, objectMapper);
case "discord" -> new DiscordChannelAdapter(channel, messageRouter, objectMapper);
case "wecom" -> new WeComChannelAdapter(channel, messageRouter, objectMapper,
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 c39d64ca..1a0faa5b 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
@@ -5,10 +5,13 @@ import com.lark.oapi.event.EventDispatcher;
import com.lark.oapi.service.im.ImService;
import com.lark.oapi.service.im.v1.model.P2MessageReceiveV1;
import lombok.extern.slf4j.Slf4j;
+import reactor.core.publisher.Flux;
+import vip.mate.agent.AgentService.StreamDelta;
import vip.mate.channel.AbstractChannelAdapter;
import vip.mate.channel.ChannelMessage;
import vip.mate.channel.ChannelMessageRouter;
import vip.mate.channel.ExponentialBackoff;
+import vip.mate.channel.StreamingChannelAdapter;
import vip.mate.channel.media.GeneratedFileScrubber;
import vip.mate.channel.media.MediaSource;
import vip.mate.channel.media.MediaUploadException;
@@ -66,7 +69,7 @@ import java.util.concurrent.TimeUnit;
* @author MateClaw Team
*/
@Slf4j
-public class FeishuChannelAdapter extends AbstractChannelAdapter {
+public class FeishuChannelAdapter extends AbstractChannelAdapter implements StreamingChannelAdapter {
public static final String CHANNEL_TYPE = "feishu";
@@ -128,10 +131,13 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
/** Scrubs {@code /api/v1/files/generated/{id}} URLs into native attachments. Nullable for legacy callers. */
private final GeneratedFileScrubber generatedFileScrubber;
+ /** CardKit streaming-card manager. Nullable for legacy callers / tests. */
+ private final FeishuStreamingCardManager streamingCardManager;
+
public FeishuChannelAdapter(ChannelEntity channelEntity,
ChannelMessageRouter messageRouter,
ObjectMapper objectMapper) {
- this(channelEntity, messageRouter, objectMapper, null, null);
+ this(channelEntity, messageRouter, objectMapper, null, null, null);
}
public FeishuChannelAdapter(ChannelEntity channelEntity,
@@ -139,9 +145,19 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
ObjectMapper objectMapper,
FeishuMediaUploader mediaUploader,
GeneratedFileScrubber generatedFileScrubber) {
+ this(channelEntity, messageRouter, objectMapper, mediaUploader, generatedFileScrubber, null);
+ }
+
+ public FeishuChannelAdapter(ChannelEntity channelEntity,
+ ChannelMessageRouter messageRouter,
+ ObjectMapper objectMapper,
+ FeishuMediaUploader mediaUploader,
+ GeneratedFileScrubber generatedFileScrubber,
+ FeishuStreamingCardManager streamingCardManager) {
super(channelEntity, messageRouter, objectMapper);
this.mediaUploader = mediaUploader;
this.generatedFileScrubber = generatedFileScrubber;
+ this.streamingCardManager = streamingCardManager;
// Feishu WebSocket reconnect: 2s→4s→8s→16s→30s, infinite retry
this.backoff = new ExponentialBackoff(2000, 30000, 2.0, -1);
}
@@ -1437,6 +1453,136 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter {
*/
static final int MAX_TEXT_MESSAGE_CHARS = 4000;
+ // ==================== StreamingChannelAdapter ====================
+
+ /**
+ * Stream consumption strategy:
+ *
+ *
{@code card_streaming_enabled=true} (default) + manager wired →
+ * create a {@code cardkit/v1} streaming card, throttle-update its
+ * markdown element on every delta, finalise on completion. The
+ * receiver sees text appearing character-by-character like a
+ * chat-app's typing animation.
+ *
Manager missing OR card creation fails OR config opts out →
+ * fall back to {@link #processStreamAsText}: accumulate every
+ * chunk and send one final regular message.
+ *
+ */
+ @Override
+ public String processStream(Flux stream, ChannelMessage message, String conversationId) {
+ if (!isCardStreamingEnabled() || streamingCardManager == null
+ || channelEntity == null) {
+ return processStreamAsText(stream, message);
+ }
+
+ String receiveId = pickReceiveId(message);
+ if (receiveId == null) {
+ log.warn("[feishu-stream] No usable receive id on message; falling back to text mode");
+ return processStreamAsText(stream, message);
+ }
+ String receiveIdType = resolveReceiveIdType(receiveId);
+
+ String sessionKey = streamingCardManager.createAndDeliver(
+ channelEntity.getId(), receiveIdType, receiveId, null);
+ if (sessionKey == null) {
+ log.info("[feishu-stream] Card create/deliver failed; falling back to text mode");
+ return processStreamAsText(stream, message);
+ }
+
+ StringBuilder accumulator = new StringBuilder();
+ try {
+ stream.doOnNext(delta -> {
+ if (delta.content() != null) {
+ accumulator.append(delta.content());
+ streamingCardManager.appendContent(sessionKey, delta.content(), false);
+ }
+ })
+ .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();
+ if (finalContent.isBlank()) {
+ finalContent = "(无回复内容)";
+ }
+ streamingCardManager.finishCard(sessionKey, finalContent);
+ log.info("[feishu-stream] Card streaming completed: sessionKey={}, contentLen={}",
+ sessionKey, finalContent.length());
+ return 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
+ // persisted row and BaseAgent.sanitizeForLlm filters it out of
+ // the next turn's history. Without this, partial streaming
+ // output (e.g. LLM 400'd mid-stream) would re-enter the prompt
+ // as a valid assistant turn and re-trigger the same 400.
+ String partial = accumulator.toString();
+ String errorPrefix = "[错误] Feishu CardKit streaming failed: " + e.getMessage();
+ if (!partial.isBlank()) {
+ return errorPrefix + "\n\n(已生成的部分内容,已忽略)\n" + partial;
+ }
+ throw new RuntimeException(errorPrefix, e);
+ }
+ }
+
+ /**
+ * Streaming fallback — accumulate all deltas, then send through the
+ * existing {@link #sendMessage} path so the message goes out as a
+ * regular text bubble (auto-upgraded to a non-streaming card by the
+ * existing {@code card_format} logic when content looks card-worthy).
+ */
+ private String processStreamAsText(Flux stream, ChannelMessage message) {
+ StringBuilder accumulator = new StringBuilder();
+ stream.doOnNext(delta -> {
+ if (delta.content() != null) {
+ accumulator.append(delta.content());
+ }
+ })
+ .blockLast(Duration.ofMinutes(5));
+ String finalContent = accumulator.toString();
+ if (!finalContent.isBlank()) {
+ String replyTarget = message.getReplyToken() != null
+ ? message.getReplyToken()
+ : (message.getChatId() != null ? message.getChatId() : message.getSenderId());
+ if (replyTarget != null) {
+ sendMessage(replyTarget, finalContent);
+ }
+ }
+ return finalContent;
+ }
+
+ /** Resolve the best id to receive a streaming card — prefer reply token, then chat, then sender. */
+ private static String pickReceiveId(ChannelMessage message) {
+ if (message == null) return null;
+ if (message.getReplyToken() != null && !message.getReplyToken().isBlank()) {
+ return message.getReplyToken();
+ }
+ if (message.getChatId() != null && !message.getChatId().isBlank()) {
+ return message.getChatId();
+ }
+ if (message.getSenderId() != null && !message.getSenderId().isBlank()) {
+ return message.getSenderId();
+ }
+ return null;
+ }
+
+ private boolean isCardStreamingEnabled() {
+ // Default true — streaming cards are the better UX when CardKit is
+ // available. Operators can flip card_streaming_enabled=false in
+ // configJson to fall back to the text path (useful for debugging
+ // or when targeting an old Feishu tenant that hasn't rolled out
+ // CardKit v1 universally).
+ return getConfigBoolean("card_streaming_enabled", true);
+ }
+
@Override
public void sendMessage(String targetId, String content) {
if (httpClient == null) {
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
new file mode 100644
index 00000000..1b883057
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuStreamingCardManager.java
@@ -0,0 +1,440 @@
+package vip.mate.channel.feishu;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.lark.oapi.Client;
+import com.lark.oapi.service.cardkit.v1.model.ContentCardElementReq;
+import com.lark.oapi.service.cardkit.v1.model.ContentCardElementReqBody;
+import com.lark.oapi.service.cardkit.v1.model.ContentCardElementResp;
+import com.lark.oapi.service.cardkit.v1.model.CreateCardReq;
+import com.lark.oapi.service.cardkit.v1.model.CreateCardReqBody;
+import com.lark.oapi.service.cardkit.v1.model.CreateCardResp;
+import com.lark.oapi.service.cardkit.v1.model.SettingsCardReq;
+import com.lark.oapi.service.cardkit.v1.model.SettingsCardReqBody;
+import com.lark.oapi.service.cardkit.v1.model.SettingsCardResp;
+import com.lark.oapi.service.im.v1.model.CreateMessageReq;
+import com.lark.oapi.service.im.v1.model.CreateMessageReqBody;
+import com.lark.oapi.service.im.v1.model.CreateMessageResp;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Streaming-card lifecycle for the Feishu CardKit v1 API.
+ *
+ *
Flow per stream:
+ *
+ *
{@link #createAndDeliver} — build a {@code streaming_mode=true}
+ * schema-2.0 card via {@code cardkit/v1/card.create}, then send
+ * it to the user via {@code im/v1/message.create} as an
+ * {@code interactive} message referencing the new {@code card_id}.
+ * Returns a session key used by subsequent calls.
+ *
{@link #appendContent} — accumulate delta text and, when the
+ * throttle window permits or the caller forces a flush, push the
+ * current accumulator to the card's markdown element via
+ * {@code cardkit/v1/cardElement.content} with a monotonic
+ * sequence number.
+ *
{@link #finishCard} — push the final full content one last
+ * time, then turn off {@code streaming_mode} via
+ * {@code cardkit/v1/card.settings} so the receiver UI stops
+ * showing the typing animation.
+ *
{@link #failCard} — append an error marker to whatever was
+ * accumulated, then close streaming the same way.
+ *
+ *
+ *
Designed mirror-image to {@code DingTalkAICardManager}: same
+ * create/append/finish/fail shape, same per-session throttling,
+ * same activeSessions map for hand-off between threads. The
+ * implementation is end-to-end {@code oapi-sdk} — no hand-rolled HTTP.
+ *
+ *
The four SDK call sites are {@code protected} so unit tests can
+ * subclass and verify session/throttle behavior without booting a real
+ * Feishu credential or hitting the network.
+ */
+@Slf4j
+@Component
+public class FeishuStreamingCardManager {
+
+ /** Throttle window for {@link #appendContent}, ms — matches DingTalk AICard. */
+ static final long THROTTLE_INTERVAL_MS = 500;
+
+ /**
+ * Markdown element id baked into the initial streaming card.
+ * Content-update calls reference this id. Public so tests can assert.
+ */
+ public static final String STREAM_ELEMENT_ID = "stream_md";
+
+ /** Default text shown when the card is first created, before any delta arrives. */
+ public static final String DEFAULT_INITIAL_TEXT = "🤔 思考中...";
+
+ private final FeishuClientFactory clientFactory;
+ private final ObjectMapper objectMapper;
+
+ /** sessionKey → CardSession. sessionKey is an opaque UUID handed back to the caller. */
+ private final ConcurrentHashMap activeSessions = new ConcurrentHashMap<>();
+
+ public FeishuStreamingCardManager(FeishuClientFactory clientFactory, ObjectMapper objectMapper) {
+ this.clientFactory = clientFactory;
+ this.objectMapper = objectMapper;
+ }
+
+ // ------------------------------------------------------------------
+ // Session state
+ // ------------------------------------------------------------------
+
+ /** Terminal-state CAS guard — at most one of {finishCard, failCard} wins per session. */
+ enum Status { STREAMING, FINISHED, FAILED }
+
+ /**
+ * One in-flight streaming card. State is mutated by a single Reactor
+ * thread per session (the one consuming the {@code Flux}), so all
+ * mutable fields are either {@code volatile} (visibility across the
+ * eventual terminal call) or guarded by the session monitor.
+ */
+ static final class CardSession {
+ final String sessionKey;
+ final Long channelId;
+ final String cardId;
+ final String messageId;
+ final StringBuilder accumulated = new StringBuilder();
+ final AtomicInteger sequence = new AtomicInteger(0);
+ final AtomicReference status = new AtomicReference<>(Status.STREAMING);
+ /**
+ * Time of the most recent flush. Initialised to a value well in
+ * the past so the very first {@link #appendContent} always
+ * flushes — the receiver sees the first token instantly instead
+ * of waiting up to {@link #THROTTLE_INTERVAL_MS} for the second.
+ */
+ volatile long lastFlushMs = Long.MIN_VALUE / 2;
+
+ CardSession(String sessionKey, Long channelId, String cardId, String messageId) {
+ this.sessionKey = sessionKey;
+ this.channelId = channelId;
+ this.cardId = cardId;
+ this.messageId = messageId;
+ }
+
+ boolean isStreaming() {
+ return status.get() == Status.STREAMING;
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // Public API
+ // ------------------------------------------------------------------
+
+ /**
+ * Build the streaming card, push it as an interactive message, and
+ * register an in-memory session.
+ *
+ * @param channelId mate_channel row id — picks SDK client
+ * @param receiveIdType one of {@code open_id} / {@code chat_id} /
+ * {@code email} / {@code union_id} /
+ * {@code user_id}
+ * @param receiveId the chat or user id to receive the card
+ * @param initialText bubble text shown before the first delta;
+ * null → {@link #DEFAULT_INITIAL_TEXT}
+ * @return sessionKey for subsequent calls, or null on failure
+ */
+ public String createAndDeliver(Long channelId, String receiveIdType,
+ String receiveId, String initialText) {
+ if (channelId == null || receiveIdType == null || receiveId == null) {
+ log.warn("[feishu-stream] createAndDeliver missing required arg(s)");
+ return null;
+ }
+ String firstText = (initialText == null || initialText.isBlank())
+ ? DEFAULT_INITIAL_TEXT
+ : initialText;
+ try {
+ Client client = clientFactory.client(channelId);
+
+ String cardId = sdkCreateCard(client, firstText);
+ if (cardId == null) {
+ return null;
+ }
+ String messageId = sdkSendInteractiveMessage(client, receiveIdType, receiveId, cardId);
+ if (messageId == null) {
+ // Card built but couldn't deliver — best effort close so the
+ // server-side card isn't orphaned in streaming mode forever.
+ tryCloseStreamingSilently(client, cardId);
+ return null;
+ }
+ String sessionKey = UUID.randomUUID().toString();
+ CardSession session = new CardSession(sessionKey, channelId, cardId, messageId);
+ activeSessions.put(sessionKey, session);
+ log.info("[feishu-stream] Card created: sessionKey={}, cardId={}, messageId={}",
+ sessionKey, abbrev(cardId), abbrev(messageId));
+ return sessionKey;
+ } catch (Exception e) {
+ log.error("[feishu-stream] createAndDeliver failed: {}", e.getMessage(), e);
+ return null;
+ }
+ }
+
+ /**
+ * Append delta text to the running session. May flush immediately
+ * (force) or wait for the next throttle window.
+ *
+ *