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