mateclaw/mateclaw-server/src/main/java/vip/mate/channel/wecom/WeComProgressRenderer.java
2026-07-31 03:54:21 -04:00

281 lines
11 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package vip.mate.channel.wecom;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Map;
/**
* Builds the live progress text shown in the WeCom stream bubble while an
* agent turn is running: a status header (thinking / tool call / replying,
* with elapsed time), the most recent tool-call lines, an optional rolling
* window of reasoning text, and the tail of the answer produced so far.
* <p>
* Mutations arrive from the stream-consuming thread; {@link #snapshot()} may
* also be called from the keepalive scheduler thread, so all state access is
* synchronized on this instance.
*/
final class WeComProgressRenderer {
/** Max completed/running tool lines rendered before collapsing to a counter. */
private static final int MAX_TOOL_LINES = 3;
/** Rolling window (chars) of reasoning text when thinking display is on. */
private static final int THINKING_WINDOW = 500;
/** Tail window (chars) of the streamed answer kept inside the bubble.
* The full answer is delivered by the final render pass; the bubble only
* needs enough to show live progress while staying under the 2048 limit. */
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 int collapsedToolCount;
private final StringBuilder thinkingTail = new StringBuilder();
private final StringBuilder answerTail = new StringBuilder();
private boolean thinkingSeen;
private boolean contentSeen;
private boolean approvalPending;
private String planStepLine;
private String narration;
/**
* @param startedAtMillis turn start, for the elapsed-time counter
* @param showThinking render the rolling reasoning window
* ({@code filter_thinking=false})
* @param showToolTrace render tool names and per-tool completion lines
* ({@code filter_tool_messages=false}). When off,
* the bubble only says that a tool is running —
* the whole point of the toggle is that the tool
* trail stays out of the user's view, and the
* progress bubble is as user-visible as a
* standalone trace message.
*/
WeComProgressRenderer(long startedAtMillis, boolean showThinking, boolean showToolTrace) {
this.startedAtMillis = startedAtMillis;
this.showThinking = showThinking;
this.showToolTrace = showToolTrace;
}
synchronized void onThinkingDelta(String delta) {
thinkingSeen = true;
if (showThinking && delta != null && !delta.isEmpty()) {
thinkingTail.append(delta);
trimLeading(thinkingTail, THINKING_WINDOW);
}
}
synchronized void onContentDelta(String delta) {
contentSeen = true;
if (delta != null && !delta.isEmpty()) {
answerTail.append(delta);
trimLeading(answerTail, ANSWER_WINDOW);
}
}
/**
* Consume a graph event. Returns true when the event changes what the
* bubble shows in a way worth flushing immediately (tool transitions,
* plan steps, approval waits) rather than waiting for the throttle tick.
*/
synchronized 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;
}
}
}
/**
* Stage the newest per-stage narration so it shows in the live bubble
* straight away. Only the newest one is kept — the previous narration has
* already been published as its own bubble by then.
*/
synchronized void onNarration(String text) {
narration = (text == null || text.isBlank()) ? null : text.trim();
}
/** True once a tool call has asked for human approval this turn. */
synchronized boolean isApprovalPending() {
return approvalPending;
}
/** Render the current progress text for the stream bubble. */
synchronized String snapshot() {
StringBuilder sb = new StringBuilder();
sb.append(statusLine());
if (planStepLine != null) {
sb.append('\n').append(planStepLine);
}
appendToolLines(sb);
if (showThinking && !contentSeen && thinkingTail.length() > 0) {
sb.append("\n\n> 💭 ").append(thinkingTail.toString().replace("\n", "\n> "));
}
if (narration != null) {
sb.append("\n\n").append(narration);
}
if (answerTail.length() > 0) {
sb.append("\n\n").append(answerTail);
}
return sb.toString();
}
private String statusLine() {
if (approvalPending) {
return "⏸️ 等待工具审批…(" + elapsed() + "";
}
if (contentSeen) {
return "✍️ 正在回复…(" + elapsed() + "";
}
ToolLine running = lastRunningTool();
if (running != null) {
return showToolTrace
? "🔧 正在调用 " + displayName(running) + "…(" + elapsed() + ""
: "🔧 正在执行工具…(" + elapsed() + "";
}
if (thinkingSeen) {
return "💭 思考中…(" + elapsed() + "";
}
return "🤔 思考中…(" + elapsed() + "";
}
private void appendToolLines(StringBuilder sb) {
if (!showToolTrace) {
// filter_tool_messages=true — the tool trail is suppressed
// everywhere the user can see it, progress bubble included.
return;
}
if (collapsedToolCount > 0) {
sb.append("\n…等 ").append(collapsedToolCount).append(" 项已完成");
}
for (ToolLine line : toolLines) {
if (line.finishedAt() == null) {
// The running tool is already the status header — skip here
// unless content started (header shows "正在回复" instead).
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;
}
boolean idMatch = callId != null && callId.equals(line.callId());
boolean nameMatch = callId == null && toolName != null && toolName.equals(line.name());
if (idMatch || nameMatch) {
match = line;
}
}
if (match == null) {
toolLines.addLast(new ToolLine(callId, toolName,
System.currentTimeMillis(), System.currentTimeMillis(), success));
} else {
ToolLine done = new ToolLine(match.callId(), match.name(),
match.startedAt(), System.currentTimeMillis(), success);
replaceLine(match, done);
}
compactToolLines();
}
private void replaceLine(ToolLine oldLine, ToolLine newLine) {
Deque<ToolLine> rebuilt = new ArrayDeque<>(toolLines.size());
for (ToolLine line : toolLines) {
rebuilt.addLast(line == oldLine ? newLine : line);
}
toolLines.clear();
toolLines.addAll(rebuilt);
}
/** Keep at most {@link #MAX_TOOL_LINES}; older completed lines collapse into a counter. */
private void compactToolLines() {
while (toolLines.size() > MAX_TOOL_LINES) {
ToolLine oldest = toolLines.peekFirst();
if (oldest != null && oldest.finishedAt() == null) {
// Never collapse a still-running tool line.
break;
}
toolLines.pollFirst();
collapsedToolCount++;
}
}
private String elapsed() {
long seconds = Math.max(0, (System.currentTimeMillis() - startedAtMillis) / 1000);
if (seconds < 60) {
return "" + seconds + "";
}
return "" + (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 void trimLeading(StringBuilder sb, int maxLen) {
int excess = sb.length() - maxLen;
if (excess > 0) {
sb.delete(0, excess);
}
}
}