mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(feishu): show streaming execution progress
This commit is contained in:
parent
8dc6c64683
commit
54eb77d7c7
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
@ -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') })
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -3881,6 +3881,8 @@ export default {
|
||||
messageReason: '基础:收发消息',
|
||||
receive: '接收消息事件',
|
||||
receiveReason: '基础:接收用户消息',
|
||||
cardkit: '创建与更新卡片',
|
||||
cardkitReason: '流式卡片:实时展示执行轨迹与回答',
|
||||
resource: '获取消息中的资源文件',
|
||||
resourceReason: 'WebSocket 模式下获取消息内容',
|
||||
reactions: '管理消息表情回复',
|
||||
|
||||
@ -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: '用户引用某条消息回复时,自动拉取被引用消息内容注入到 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' },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user