mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +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.workspace.core.service.ChatUploadLocationResolver;
|
||||||
import vip.mate.channel.ChannelMessageRouter;
|
import vip.mate.channel.ChannelMessageRouter;
|
||||||
import vip.mate.channel.ExponentialBackoff;
|
import vip.mate.channel.ExponentialBackoff;
|
||||||
|
import vip.mate.channel.ProvisionalContentTracker;
|
||||||
import vip.mate.channel.StreamingChannelAdapter;
|
import vip.mate.channel.StreamingChannelAdapter;
|
||||||
import vip.mate.channel.media.GeneratedFileScrubber;
|
import vip.mate.channel.media.GeneratedFileScrubber;
|
||||||
import vip.mate.channel.media.MediaSource;
|
import vip.mate.channel.media.MediaSource;
|
||||||
@ -67,6 +68,10 @@ import java.util.concurrent.TimeUnit;
|
|||||||
* - card_format: 卡片格式化模式 "auto"(默认)| "always" | "never"
|
* - card_format: 卡片格式化模式 "auto"(默认)| "always" | "never"
|
||||||
* auto: 根据内容自动检测;always: 全部包卡片;never: 全部纯文本(降级/调试用)
|
* auto: 根据内容自动检测;always: 全部包卡片;never: 全部纯文本(降级/调试用)
|
||||||
* - card_header: Markdown 卡片 header 文案,默认 "AI 助手";设为空串可隐藏 header
|
* - card_header: Markdown 卡片 header 文案,默认 "AI 助手";设为空串可隐藏 header
|
||||||
|
* - card_streaming_enabled: 是否启用 CardKit 流式卡片(默认 true)
|
||||||
|
* - stream_progress: 是否在流式卡片中展示执行轨迹(默认 true)
|
||||||
|
* - filter_thinking: 是否隐藏原始思考文本(默认 true;状态与阶段轨迹仍展示)
|
||||||
|
* - filter_tool_messages: 是否隐藏工具名称与逐项状态(默认 true;仍展示汇总数量)
|
||||||
* - require_mention: 群聊中是否需要 @机器人 才响应(默认 false)
|
* - require_mention: 群聊中是否需要 @机器人 才响应(默认 false)
|
||||||
* true: 仅当消息中 @了机器人才处理;通过飞书 mentions 字段精确判断,无需配置 botPrefix
|
* true: 仅当消息中 @了机器人才处理;通过飞书 mentions 字段精确判断,无需配置 botPrefix
|
||||||
*
|
*
|
||||||
@ -2596,29 +2601,70 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
}
|
}
|
||||||
|
|
||||||
StringBuilder accumulator = new StringBuilder();
|
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 {
|
try {
|
||||||
stream.doOnNext(delta -> {
|
stream.doOnNext(delta -> {
|
||||||
// segmentOnly narration is skipped: appending every
|
if (!progressEnabled) {
|
||||||
// ReAct iteration's "我来查一下…" into the card text is
|
// Legacy answer-only card mode.
|
||||||
// what makes the answer read as if it were sent twice.
|
if (StreamingChannelAdapter.contributesToFinalContent(delta)) {
|
||||||
if (StreamingChannelAdapter.contributesToFinalContent(delta)) {
|
accumulator.append(delta.content());
|
||||||
accumulator.append(delta.content());
|
streamingCardManager.appendContent(sessionKey, delta.content(), false);
|
||||||
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 -> {
|
.doOnError(err -> {
|
||||||
log.error("[feishu-stream] stream error: sessionKey={}, err={}",
|
log.error("[feishu-stream] stream error: sessionKey={}, err={}",
|
||||||
sessionKey, err.getMessage());
|
sessionKey, err.getMessage());
|
||||||
streamingCardManager.failCard(sessionKey, err.getMessage());
|
|
||||||
})
|
})
|
||||||
.blockLast(Duration.ofMinutes(5));
|
.blockLast(Duration.ofMinutes(5));
|
||||||
|
|
||||||
String finalContent = accumulator.toString();
|
String finalContent = accumulator.toString();
|
||||||
// Card streaming never touches renderAndSend, so the channel's
|
// Card streaming never touches renderAndSend, so apply the same
|
||||||
// message-filter config has to be applied here — otherwise
|
// outbound filters before the final card snapshot is assembled.
|
||||||
// filter_thinking / filter_tool_messages are inert on this path.
|
|
||||||
String cardContent = filterOutboundContent(finalContent);
|
String cardContent = filterOutboundContent(finalContent);
|
||||||
if (cardContent.isBlank()) {
|
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 = "(无回复内容)";
|
cardContent = "(无回复内容)";
|
||||||
}
|
}
|
||||||
// Strip any /api/v1/files/generated/{id} URLs out of the card
|
// 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
|
// actual file. Cache-miss URLs fall back to the user-facing
|
||||||
// retry hint that GeneratedFileScrubber emits.
|
// retry hint that GeneratedFileScrubber emits.
|
||||||
String renderedContent = scrubAndSendAttachments(receiveId, cardContent);
|
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={}",
|
log.info("[feishu-stream] Card streaming completed: sessionKey={}, contentLen={}",
|
||||||
sessionKey, renderedContent.length());
|
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) {
|
} catch (Exception e) {
|
||||||
log.error("[feishu-stream] Card streaming failed: sessionKey={}, err={}",
|
log.error("[feishu-stream] Card streaming failed: sessionKey={}, err={}",
|
||||||
sessionKey, e.getMessage(), e);
|
sessionKey, e.getMessage(), e);
|
||||||
streamingCardManager.failCard(sessionKey, e.getMessage());
|
|
||||||
|
|
||||||
// Tag returned content with the "[错误] " prefix so
|
// Tag returned content with the "[错误] " prefix so
|
||||||
// ChannelMessageRouter.isErrorReply flips status='error' on the
|
// 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.
|
// as a valid assistant turn and re-trigger the same 400.
|
||||||
String partial = accumulator.toString();
|
String partial = accumulator.toString();
|
||||||
String errorPrefix = "[错误] Feishu CardKit streaming failed: " + e.getMessage();
|
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()) {
|
if (!partial.isBlank()) {
|
||||||
return errorPrefix + "\n\n(已生成的部分内容,已忽略)\n" + partial;
|
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
|
* Streaming fallback — accumulate all deltas, then send through the
|
||||||
* existing {@link #sendMessage} path so the message goes out as a
|
* 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. */
|
/** Throttle window for {@link #appendContent}, ms — matches DingTalk AICard. */
|
||||||
static final long THROTTLE_INTERVAL_MS = 500;
|
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.
|
* Markdown element id baked into the initial streaming card.
|
||||||
* Content-update calls reference this id. Public so tests can assert.
|
* 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. */
|
/** Terminal-state CAS guard — at most one of {finishCard, failCard} wins per session. */
|
||||||
enum Status { STREAMING, FINISHED, FAILED }
|
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
|
* One in-flight streaming card. State is mutated by a single Reactor
|
||||||
* thread per session (the one consuming the {@code Flux}), so all
|
* 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
|
* Append delta text to the running session. A forced update bypasses the
|
||||||
* (force) or wait for the next throttle window.
|
* normal 500ms UX throttle but still respects the platform hard limit.
|
||||||
*
|
*
|
||||||
* <p>No-op when {@code sessionKey} is unknown or the session has
|
* <p>No-op when {@code sessionKey} is unknown or the session has
|
||||||
* already reached a terminal status — keeps the caller's
|
* already reached a terminal status — keeps the caller's
|
||||||
@ -195,11 +209,22 @@ public class FeishuStreamingCardManager {
|
|||||||
session.accumulated.append(contentDelta);
|
session.accumulated.append(contentDelta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
long now = currentTimeMs();
|
flushWithPolicy(session, forceFlush);
|
||||||
if (!forceFlush && now - session.lastFlushMs < THROTTLE_INTERVAL_MS) {
|
}
|
||||||
return;
|
|
||||||
}
|
/**
|
||||||
flush(session, now);
|
* 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
|
* a second call is a no-op. After return, the sessionKey is no
|
||||||
* longer known to the manager.
|
* longer known to the manager.
|
||||||
*/
|
*/
|
||||||
public void finishCard(String sessionKey, String finalContent) {
|
public FinishResult finishCard(String sessionKey, String finalContent) {
|
||||||
CardSession session = activeSessions.get(sessionKey);
|
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)) {
|
if (!session.status.compareAndSet(Status.STREAMING, Status.FINISHED)) {
|
||||||
return;
|
return new FinishResult(false, false);
|
||||||
}
|
}
|
||||||
|
boolean contentUpdated = false;
|
||||||
|
boolean streamingClosed = false;
|
||||||
try {
|
try {
|
||||||
replaceAccumulated(session, finalContent != null ? finalContent : "");
|
replaceAccumulated(session, finalContent != null ? finalContent : "");
|
||||||
flush(session, currentTimeMs());
|
contentUpdated = flushWithRetry(session);
|
||||||
closeStreaming(session);
|
streamingClosed = closeStreamingWithRetry(session, summaryFor(finalContent));
|
||||||
|
return new FinishResult(contentUpdated, streamingClosed);
|
||||||
} finally {
|
} finally {
|
||||||
activeSessions.remove(sessionKey);
|
activeSessions.remove(sessionKey);
|
||||||
log.info("[feishu-stream] Card finished: sessionKey={}, contentLen={}",
|
log.info("[feishu-stream] Card finished: sessionKey={}, contentLen={}, contentUpdated={}, closed={}",
|
||||||
sessionKey, finalContent == null ? 0 : finalContent.length());
|
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.
|
* suffix; the card is closed so the typing animation stops.
|
||||||
* Idempotent.
|
* Idempotent.
|
||||||
*/
|
*/
|
||||||
public void failCard(String sessionKey, String errorMessage) {
|
public FinishResult failCard(String sessionKey, String errorMessage) {
|
||||||
CardSession session = activeSessions.get(sessionKey);
|
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)) {
|
if (!session.status.compareAndSet(Status.STREAMING, Status.FAILED)) {
|
||||||
return;
|
return new FinishResult(false, false);
|
||||||
}
|
}
|
||||||
|
boolean contentUpdated = false;
|
||||||
|
boolean streamingClosed = false;
|
||||||
try {
|
try {
|
||||||
String tail;
|
String tail;
|
||||||
synchronized (session) {
|
synchronized (session) {
|
||||||
@ -246,11 +277,13 @@ public class FeishuStreamingCardManager {
|
|||||||
session.accumulated.setLength(0);
|
session.accumulated.setLength(0);
|
||||||
session.accumulated.append(tail);
|
session.accumulated.append(tail);
|
||||||
}
|
}
|
||||||
flush(session, currentTimeMs());
|
contentUpdated = flushWithRetry(session);
|
||||||
closeStreaming(session);
|
streamingClosed = closeStreamingWithRetry(session, "⚠️ 处理失败");
|
||||||
|
return new FinishResult(contentUpdated, streamingClosed);
|
||||||
} finally {
|
} finally {
|
||||||
activeSessions.remove(sessionKey);
|
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
|
// 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;
|
String snapshot;
|
||||||
synchronized (session) {
|
synchronized (session) {
|
||||||
snapshot = session.accumulated.toString();
|
snapshot = session.accumulated.toString();
|
||||||
@ -281,27 +333,45 @@ public class FeishuStreamingCardManager {
|
|||||||
try {
|
try {
|
||||||
Client client = clientFactory.client(session.channelId);
|
Client client = clientFactory.client(session.channelId);
|
||||||
sdkPushElementContent(client, session.cardId, STREAM_ELEMENT_ID, snapshot, seq);
|
sdkPushElementContent(client, session.cardId, STREAM_ELEMENT_ID, snapshot, seq);
|
||||||
session.lastFlushMs = now;
|
return true;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[feishu-stream] flush failed: sessionKey={}, seq={}, err={}",
|
log.warn("[feishu-stream] flush failed: sessionKey={}, seq={}, err={}",
|
||||||
session.sessionKey, seq, e.getMessage());
|
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();
|
int seq = session.sequence.incrementAndGet();
|
||||||
try {
|
try {
|
||||||
Client client = clientFactory.client(session.channelId);
|
Client client = clientFactory.client(session.channelId);
|
||||||
sdkCloseStreamingMode(client, session.cardId, seq);
|
sdkCloseStreamingMode(client, session.cardId, seq, summary);
|
||||||
|
return true;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[feishu-stream] closeStreaming failed: sessionKey={}, err={}",
|
log.warn("[feishu-stream] closeStreaming failed: sessionKey={}, err={}",
|
||||||
session.sessionKey, e.getMessage());
|
session.sessionKey, e.getMessage());
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
session.lastFlushMs = currentTimeMs();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void tryCloseStreamingSilently(Client client, String cardId) {
|
private void tryCloseStreamingSilently(Client client, String cardId) {
|
||||||
try {
|
try {
|
||||||
sdkCloseStreamingMode(client, cardId, 1);
|
sdkCloseStreamingMode(client, cardId, 1, "⚠️ 卡片发送失败");
|
||||||
} catch (Exception ignore) {
|
} catch (Exception ignore) {
|
||||||
// best-effort — already in an error path
|
// 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)
|
// SDK seams (overridable in tests)
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@ -373,15 +468,18 @@ public class FeishuStreamingCardManager {
|
|||||||
.build();
|
.build();
|
||||||
ContentCardElementResp resp = client.cardkit().v1().cardElement().content(req);
|
ContentCardElementResp resp = client.cardkit().v1().cardElement().content(req);
|
||||||
if (!resp.success()) {
|
if (!resp.success()) {
|
||||||
log.warn("[feishu-stream] cardElement.content failed: cardId={}, seq={}, code={}, msg={}",
|
throw new IllegalStateException("cardElement.content failed: cardId=" + abbrev(cardId)
|
||||||
abbrev(cardId), sequence, resp.getCode(), resp.getMsg());
|
+ ", seq=" + sequence + ", code=" + resp.getCode() + ", msg=" + resp.getMsg());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Flip streaming_mode=false so the receiving UI stops the typing animation. */
|
/** 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(
|
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()
|
SettingsCardReq req = SettingsCardReq.newBuilder()
|
||||||
.cardId(cardId)
|
.cardId(cardId)
|
||||||
@ -393,8 +491,8 @@ public class FeishuStreamingCardManager {
|
|||||||
.build();
|
.build();
|
||||||
SettingsCardResp resp = client.cardkit().v1().card().settings(req);
|
SettingsCardResp resp = client.cardkit().v1().card().settings(req);
|
||||||
if (!resp.success()) {
|
if (!resp.success()) {
|
||||||
log.warn("[feishu-stream] card.settings (close) failed: cardId={}, code={}, msg={}",
|
throw new IllegalStateException("card.settings failed: cardId=" + abbrev(cardId)
|
||||||
abbrev(cardId), resp.getCode(), resp.getMsg());
|
+ ", 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.
|
Replies stream char-by-char into a **single card** instead of waiting for the whole answer before sending.
|
||||||
|
|
||||||
- `card_streaming_enabled` (default `true`)
|
- `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
|
- 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
|
#### Inbound voice transcription
|
||||||
|
|
||||||
@ -354,6 +358,7 @@ curl -X POST http://localhost:18088/api/v1/channels \
|
|||||||
"card_format": "auto",
|
"card_format": "auto",
|
||||||
"card_header": "AI 助手",
|
"card_header": "AI 助手",
|
||||||
"card_streaming_enabled": true,
|
"card_streaming_enabled": true,
|
||||||
|
"stream_progress": true,
|
||||||
"media_download_enabled": true,
|
"media_download_enabled": true,
|
||||||
"enable_done_reaction": true,
|
"enable_done_reaction": true,
|
||||||
"require_mention": false
|
"require_mention": false
|
||||||
|
|||||||
@ -295,8 +295,12 @@ JSON 卡片 payload 上限约 32 KB,超出后自动降级为纯文本。
|
|||||||
回复逐字刷新进**同一张卡片**,而不是等整段生成完再发。
|
回复逐字刷新进**同一张卡片**,而不是等整段生成完再发。
|
||||||
|
|
||||||
- `card_streaming_enabled`(默认 `true`)
|
- `card_streaming_enabled`(默认 `true`)
|
||||||
- 首 token 立即出现,之后按 500ms 节流刷新
|
- 首 token 立即出现;普通文本按 500ms 合并刷新,阶段切换遵守 120ms 平台硬限流后优先刷新
|
||||||
|
- `stream_progress`(默认 `true`):同一卡片会展示思考状态、计划步骤、工具进度和阶段旁白,完成后保留一份有界执行轨迹并追加最终回答
|
||||||
|
- `filter_thinking=false` 时展示模型原始思考文本;默认仅展示状态与阶段轨迹,不暴露原始思考
|
||||||
|
- `filter_tool_messages=false` 时展示工具名称和逐项结果;默认只展示工具执行数量
|
||||||
- CardKit 调用失败时自动回退到"先攒齐再一次性发出"
|
- CardKit 调用失败时自动回退到"先攒齐再一次性发出"
|
||||||
|
- 最终更新和关闭操作会自动重试一次;仍失败则通过普通飞书消息兜底
|
||||||
|
|
||||||
#### 入站语音转写
|
#### 入站语音转写
|
||||||
|
|
||||||
@ -354,6 +358,7 @@ curl -X POST http://localhost:18088/api/v1/channels \
|
|||||||
"card_format": "auto",
|
"card_format": "auto",
|
||||||
"card_header": "AI 助手",
|
"card_header": "AI 助手",
|
||||||
"card_streaming_enabled": true,
|
"card_streaming_enabled": true,
|
||||||
|
"stream_progress": true,
|
||||||
"media_download_enabled": true,
|
"media_download_enabled": true,
|
||||||
"enable_done_reaction": true,
|
"enable_done_reaction": true,
|
||||||
"require_mention": false
|
"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.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
@ -31,7 +32,7 @@ import static org.mockito.Mockito.when;
|
|||||||
* <p>Behaviour pinned:
|
* <p>Behaviour pinned:
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>throttle window suppresses sub-window flushes; forceFlush
|
* <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
|
* <li>session is removed from {@code activeSessions} on terminal
|
||||||
* transition; subsequent appends are no-ops</li>
|
* transition; subsequent appends are no-ops</li>
|
||||||
* <li>finish vs fail is a CAS-guarded one-shot — second terminal
|
* <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. */
|
/** Recording SDK seam — captures each call so the test can replay them. */
|
||||||
private static final class RecordingManager extends FeishuStreamingCardManager {
|
private static final class RecordingManager extends FeishuStreamingCardManager {
|
||||||
record ContentCall(String cardId, String elementId, String content, int sequence) {}
|
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<ContentCall> contentCalls = new java.util.concurrent.CopyOnWriteArrayList<>();
|
||||||
final List<CloseCall> closeCalls = new java.util.concurrent.CopyOnWriteArrayList<>();
|
final List<CloseCall> closeCalls = new java.util.concurrent.CopyOnWriteArrayList<>();
|
||||||
final AtomicLong fakeNowMs = new AtomicLong(0);
|
final AtomicLong fakeNowMs = new AtomicLong(0);
|
||||||
final AtomicReference<String> nextCardId = new AtomicReference<>("card_abc");
|
final AtomicReference<String> nextCardId = new AtomicReference<>("card_abc");
|
||||||
final AtomicReference<String> nextMessageId = new AtomicReference<>("msg_abc");
|
final AtomicReference<String> nextMessageId = new AtomicReference<>("msg_abc");
|
||||||
|
final AtomicInteger contentFailuresRemaining = new AtomicInteger();
|
||||||
|
final AtomicInteger closeFailuresRemaining = new AtomicInteger();
|
||||||
|
|
||||||
RecordingManager(FeishuClientFactory factory, ObjectMapper objectMapper) {
|
RecordingManager(FeishuClientFactory factory, ObjectMapper objectMapper) {
|
||||||
super(factory, objectMapper);
|
super(factory, objectMapper);
|
||||||
@ -69,11 +72,22 @@ class FeishuStreamingCardManagerTest {
|
|||||||
|
|
||||||
@Override protected void sdkPushElementContent(Client client, String cardId, String elementId,
|
@Override protected void sdkPushElementContent(Client client, String cardId, String elementId,
|
||||||
String content, int sequence) {
|
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));
|
contentCalls.add(new ContentCall(cardId, elementId, content, sequence));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override protected void sdkCloseStreamingMode(Client client, String cardId, int sequence) {
|
@Override protected void sdkCloseStreamingMode(Client client, String cardId, int sequence,
|
||||||
closeCalls.add(new CloseCall(cardId, 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());
|
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
|
@Test
|
||||||
@DisplayName("finishCard emits final content + close, in monotonic sequence order, then removes session")
|
@DisplayName("finishCard emits final content + close, in monotonic sequence order, then removes session")
|
||||||
void finishCardClosesAndUnregisters() {
|
void finishCardClosesAndUnregisters() {
|
||||||
@ -173,7 +203,8 @@ class FeishuStreamingCardManagerTest {
|
|||||||
manager.appendContent(key, "Hello, ", true); // seq 1
|
manager.appendContent(key, "Hello, ", true); // seq 1
|
||||||
manager.fakeNowMs.set(600L);
|
manager.fakeNowMs.set(600L);
|
||||||
manager.appendContent(key, "world", false); // seq 2
|
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(3, manager.contentCalls.size());
|
||||||
assertEquals("Hello, world!", manager.contentCalls.get(2).content());
|
assertEquals("Hello, world!", manager.contentCalls.get(2).content());
|
||||||
@ -183,9 +214,45 @@ class FeishuStreamingCardManagerTest {
|
|||||||
assertEquals(3, manager.contentCalls.get(2).sequence());
|
assertEquals(3, manager.contentCalls.get(2).sequence());
|
||||||
assertEquals(1, manager.closeCalls.size());
|
assertEquals(1, manager.closeCalls.size());
|
||||||
assertEquals(4, manager.closeCalls.get(0).sequence());
|
assertEquals(4, manager.closeCalls.get(0).sequence());
|
||||||
|
assertEquals("Hello, world!", manager.closeCalls.get(0).summary());
|
||||||
|
assertTrue(result.success());
|
||||||
assertEquals(0, manager.activeSessionCount());
|
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
|
@Test
|
||||||
@DisplayName("appendContent after finish is a no-op (no SDK call, no resurrection)")
|
@DisplayName("appendContent after finish is a no-op (no SDK call, no resurrection)")
|
||||||
void appendAfterFinishIsNoop() {
|
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', 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') },
|
{ 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') {
|
if (channelConfig.value?.connection_mode === 'websocket') {
|
||||||
perms.push({ scope: 'im:resource', desc: t('channels.feishu.perm.resource'), reason: t('channels.feishu.perm.resourceReason') })
|
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',
|
messageReason: 'Core: send and receive messages',
|
||||||
receive: 'Receive message events',
|
receive: 'Receive message events',
|
||||||
receiveReason: 'Core: receive user messages',
|
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',
|
resource: 'Access message resources',
|
||||||
resourceReason: 'Get message content in WebSocket mode',
|
resourceReason: 'Get message content in WebSocket mode',
|
||||||
reactions: 'Manage message reactions',
|
reactions: 'Manage message reactions',
|
||||||
|
|||||||
@ -3881,6 +3881,8 @@ export default {
|
|||||||
messageReason: '基础:收发消息',
|
messageReason: '基础:收发消息',
|
||||||
receive: '接收消息事件',
|
receive: '接收消息事件',
|
||||||
receiveReason: '基础:接收用户消息',
|
receiveReason: '基础:接收用户消息',
|
||||||
|
cardkit: '创建与更新卡片',
|
||||||
|
cardkitReason: '流式卡片:实时展示执行轨迹与回答',
|
||||||
resource: '获取消息中的资源文件',
|
resource: '获取消息中的资源文件',
|
||||||
resourceReason: 'WebSocket 模式下获取消息内容',
|
resourceReason: 'WebSocket 模式下获取消息内容',
|
||||||
reactions: '管理消息表情回复',
|
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_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: '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: '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: [
|
telegram: [
|
||||||
{ key: 'bot_token', label: 'Bot Token', placeholder: '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11', required: true, sensitive: true, type: 'password', tooltip: '从 @BotFather 获取的 Bot Token' },
|
{ 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