package vip.mate.channel.web; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import reactor.core.Disposable; import java.io.IOException; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; /** * 聊天流状态追踪器 *
* 采用生产者-消费者解耦设计:将 SSE 事件的生产(Flux 订阅)与消费(SseEmitter 连接)解耦。
* 一个后台 Flux 生产者持续产出事件,广播给所有 SseEmitter 订阅者并缓存到 buffer。
* 新连接(重连)到来时,先回放 buffer,再接入实时流。
*
* @author MateClaw Team
*/
@Slf4j
@Component
public class ChatStreamTracker {
/** buffer 最大事件数,超出后丢弃最早的 thinking_delta 事件以释放空间 */
private static final int MAX_BUFFER_SIZE = 16000;
private final ObjectMapper objectMapper;
public ChatStreamTracker(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
record SseEvent(String name, String json) {}
/**
* 中断类型:区分用户主动停止和用户在运行中追加新消息
*/
public enum InterruptType {
/** 用户点击 Stop,终止当前 turn,不自动续跑 */
USER_STOP,
/** 用户在执行中追加新消息,中断当前 turn 后自动续跑排队消息 */
USER_INTERRUPT_WITH_FOLLOWUP
}
static final class RunState {
final String conversationId;
final List
* 用于在 Node 内部直接向前端推送 SSE 事件,绕过 NodeOutput 管道。
* 典型场景:审批请求在 awaitDecision() 阻塞前必须先送达前端。
*
* @param conversationId 会话 ID
* @param eventName SSE 事件名称(如 tool_approval_requested)
* @param data 事件载荷,将被 Jackson 序列化为 JSON
*/
public void broadcastObject(String conversationId, String eventName, Object data) {
String json;
try {
json = objectMapper.writeValueAsString(data);
} catch (Exception e) {
log.warn("Failed to serialize broadcast data for event {}: {}", eventName, e.getMessage());
json = "{\"error\":\"serialization_failed\"}";
}
broadcast(conversationId, eventName, json);
}
/**
* 将 emitter 附着到现有的运行中的流。
* 先回放 buffer 中的全部事件,再加入订阅者列表接收后续实时事件。
*
* @return true 如果成功附着(流正在运行),false 如果没有活跃的流
*/
public boolean attach(String conversationId, SseEmitter emitter) {
RunState state = runs.get(conversationId);
if (state == null || state.done) {
return false;
}
synchronized (state.lock) {
if (state.done) {
return false;
}
// 回放全部缓冲事件
for (SseEvent event : state.buffer) {
try {
emitter.send(SseEmitter.event().name(event.name()).data(event.json()));
} catch (IOException | IllegalStateException e) {
log.warn("Failed to replay buffer to reconnecting client for {}: {}",
conversationId, e.getMessage());
return false;
}
}
state.subscribers.add(emitter);
}
log.info("[SSE] Client reconnected for conversation={}, replaying {} buffered events",
conversationId, state.buffer.size());
return true;
}
/**
* 递增活跃 Flux 计数(每个 Flux 订阅开始时调用)。
* 原始流和审批 Replay 流共享同一个 RunState,通过计数协调生命周期。
*/
public void incrementFlux(String conversationId) {
RunState state = runs.get(conversationId);
if (state != null) {
synchronized (state.lock) {
state.activeFluxCount++;
log.debug("Flux count incremented: {} (count={})", conversationId, state.activeFluxCount);
}
}
}
/**
* 完成结果:包含是否全部完成、排队消息快照
*/
public record CompletionResult(boolean allDone, QueuedInput queuedInput) {}
/**
* 标记一个 Flux 完成。仅在所有 Flux 都完成时才真正移除 RunState。
*
* 这解决了"原始流完成关闭 SSE,但 Replay 流仍在运行"的竞态问题。
*
* 无副作用:不消费排队消息。适用于不关心 queue 的路径(approval deny、setup error 等)。
* 需要链式续跑的路径应使用 {@link #completeAndConsumeIfLast(String)}。
*
* @return true 如果这是最后一个 Flux(RunState 已被移除),false 如果仍有活跃 Flux
*/
public boolean complete(String conversationId) {
RunState state = runs.get(conversationId);
if (state == null) {
return true;
}
synchronized (state.lock) {
state.activeFluxCount = Math.max(0, state.activeFluxCount - 1);
if (state.activeFluxCount > 0) {
log.debug("Stream partially completed (no queue drain): {} (remaining flux={})",
conversationId, state.activeFluxCount);
return false;
}
}
// 所有 Flux 都已完成,停止心跳并移除 RunState(不消费 queue)
stopHeartbeat(conversationId);
runs.remove(conversationId);
state.done = true;
log.debug("Stream fully completed (no queue drain): {}", conversationId);
return true;
}
/**
* 原子地递减 activeFluxCount,仅在最后一个 Flux 完成时消费排队消息并移除 RunState。
*
* 将「递减计数 → 消费 queue → 删除 RunState」三步收口到同一个临界区,
* 避免非最后一个 flux 提前 consume 导致 queue 丢失,也避免 complete 后查不到 queue。
*
* @return CompletionResult(allDone, queuedInput)
*/
public CompletionResult completeAndConsumeIfLast(String conversationId) {
RunState state = runs.get(conversationId);
if (state == null) {
return new CompletionResult(true, null);
}
QueuedInput consumed = null;
synchronized (state.lock) {
state.activeFluxCount = Math.max(0, state.activeFluxCount - 1);
if (state.activeFluxCount > 0) {
log.debug("Stream partially completed: {} (remaining flux={}, queuePreserved={})",
conversationId, state.activeFluxCount, !state.messageQueue.isEmpty());
return new CompletionResult(false, null);
}
// 最后一个 Flux:在同一个锁内消费排队消息(取队首)
consumed = state.messageQueue.poll();
}
// 锁外:停止心跳并移除 RunState
stopHeartbeat(conversationId);
runs.remove(conversationId);
state.done = true;
log.debug("Stream fully completed: {} (hasQueuedSnapshot={})", conversationId, consumed != null);
return new CompletionResult(true, consumed);
}
/**
* 检查指定会话是否有正在运行的流
*/
public boolean isRunning(String conversationId) {
RunState state = runs.get(conversationId);
return state != null && !state.done;
}
/**
* 从订阅者列表中移除指定 emitter(连接断开/超时时调用)
*/
public void detach(String conversationId, SseEmitter emitter) {
RunState state = runs.get(conversationId);
if (state == null) {
return;
}
synchronized (state.lock) {
state.subscribers.remove(emitter);
}
log.debug("Emitter detached from stream: {} (remaining={})",
conversationId, state.subscribers.size());
}
// ===== Heartbeat =====
/** 心跳间隔(秒) */
private static final int HEARTBEAT_INTERVAL_SEC = 10;
/**
* 启动心跳定时器。在流注册后调用,定期向前端发送 heartbeat 事件。
* 防止 useStream 的 60 秒无数据 timeout 误杀等待审批/长工具的流。
*/
public void startHeartbeat(String conversationId) {
RunState state = runs.get(conversationId);
if (state == null) return;
// 避免重复启动
if (state.heartbeatFuture != null && !state.heartbeatFuture.isDone()) return;
state.heartbeatFuture = heartbeatScheduler.scheduleAtFixedRate(() -> {
try {
RunState s = runs.get(conversationId);
if (s == null || s.done) {
stopHeartbeat(conversationId);
return;
}
String json;
try {
json = objectMapper.writeValueAsString(Map.of(
"conversationId", conversationId,
"currentPhase", safe(s.currentPhase),
"waitingReason", safe(s.waitingReason),
"runningToolName", safe(s.runningToolName),
"queueLength", s.messageQueue.size(),
"timestamp", System.currentTimeMillis()
));
} catch (Exception e) {
json = "{\"conversationId\":\"" + conversationId + "\"}";
}
broadcast(conversationId, "heartbeat", json);
} catch (Exception e) {
log.debug("Heartbeat error for {}: {}", conversationId, e.getMessage());
}
}, HEARTBEAT_INTERVAL_SEC, HEARTBEAT_INTERVAL_SEC, TimeUnit.SECONDS);
}
/**
* 停止心跳定时器
*/
public void stopHeartbeat(String conversationId) {
RunState state = runs.get(conversationId);
if (state != null && state.heartbeatFuture != null) {
state.heartbeatFuture.cancel(false);
state.heartbeatFuture = null;
}
}
// ===== Phase tracking =====
/**
* 更新当前执行阶段(用于 heartbeat 和前端状态展示)
*/
public void updatePhase(String conversationId, String phase) {
RunState state = runs.get(conversationId);
if (state != null) {
state.currentPhase = phase;
}
}
/**
* 更新当前正在执行的工具名称
*/
public void updateRunningTool(String conversationId, String toolName) {
RunState state = runs.get(conversationId);
if (state != null) {
state.runningToolName = toolName;
}
}
/**
* 设置等待原因
*/
public void setWaitingReason(String conversationId, String reason) {
RunState state = runs.get(conversationId);
if (state != null) {
state.waitingReason = reason;
}
}
// ===== Interrupt with follow-up =====
/**
* 请求中断当前流并排队一条用户消息。
* 与 requestStop 的区别:中断后自动续跑排队消息,而非停在原地。
*
* @return true 如果成功请求了中断
*/
public boolean requestInterrupt(String conversationId, String queuedMessage, Long agentId, boolean persisted) {
RunState state = runs.get(conversationId);
if (state == null || state.done) {
return false;
}
// 在锁内完成入队和 Disposable 可用性判断,锁外执行 dispose/broadcast
Disposable toDispose = null;
boolean canInterrupt;
synchronized (state.lock) {
Disposable d = state.disposable;
canInterrupt = d != null && !d.isDisposed();
// 无论是否可中断,都入队(支持多条排队消息)
state.messageQueue.offer(new QueuedInput(queuedMessage, agentId, persisted));
if (canInterrupt) {
state.interruptType = InterruptType.USER_INTERRUPT_WITH_FOLLOWUP;
state.stopRequested.set(true);
toDispose = d;
}
// 不可中断时不设 interruptType / stopRequested
}
// 锁外执行 dispose 和 broadcast(这些可能阻塞或耗时)
if (canInterrupt) {
toDispose.dispose();
log.info("Stream interrupted for follow-up: {} (queued: {})", conversationId,
queuedMessage != null ? queuedMessage.substring(0, Math.min(30, queuedMessage.length())) : "null");
try {
String json = objectMapper.writeValueAsString(Map.of(
"conversationId", conversationId,
"queuedMessage", queuedMessage != null ? queuedMessage : "",
"timestamp", System.currentTimeMillis()
));
broadcast(conversationId, "turn_interrupt_requested", json);
} catch (Exception e) {
log.warn("Failed to broadcast turn_interrupt_requested: {}", e.getMessage());
}
return true;
}
log.info("Interrupt requested but Disposable unavailable, message queued only: {} (queued: {})",
conversationId,
queuedMessage != null ? queuedMessage.substring(0, Math.min(30, queuedMessage.length())) : "null");
try {
String json = objectMapper.writeValueAsString(Map.of(
"conversationId", conversationId,
"queuedMessage", queuedMessage != null ? queuedMessage : "",
"timestamp", System.currentTimeMillis()
));
broadcast(conversationId, "queued_input_accepted", json);
} catch (Exception e) {
log.warn("Failed to broadcast queued_input_accepted: {}", e.getMessage());
}
return false;
}
/**
* 将消息加入队列但不中断当前执行(用于不可中断阶段)。
*/
public boolean enqueueMessage(String conversationId, String message, Long agentId, boolean persisted) {
RunState state = runs.get(conversationId);
if (state == null || state.done) {
return false;
}
state.messageQueue.offer(new QueuedInput(message, agentId, persisted));
// broadcast 在锁外
try {
String json = objectMapper.writeValueAsString(Map.of(
"conversationId", conversationId,
"queuedMessage", message,
"timestamp", System.currentTimeMillis()
));
broadcast(conversationId, "queued_input_accepted", json);
} catch (Exception e) {
log.warn("Failed to broadcast queued_input_accepted: {}", e.getMessage());
}
return true;
}
/**
* 排队输入的原子快照(message + agentId + persisted 一起返回,避免分离读取导致不一致)
*/
public record QueuedInput(String message, Long agentId, boolean persisted) {}
/**
* 原子消费排队的输入(流完成/中断后调用)。
* 从队列头部取出一条消息。
*/
public QueuedInput consumeQueuedInput(String conversationId) {
RunState state = runs.get(conversationId);
if (state == null) return null;
return state.messageQueue.poll();
}
/**
* @deprecated Use {@link #consumeQueuedInput(String)} instead.
*/
@Deprecated
public String consumeQueuedMessage(String conversationId) {
QueuedInput input = consumeQueuedInput(conversationId);
return input != null ? input.message() : null;
}
/**
* @deprecated 多消息队列模式下,改为在入队时直接传入 persisted 参数。
*/
@Deprecated
public boolean markQueuedMessagePersisted(String conversationId) {
// 向后兼容:无操作(persisted 已在入队时设定)
return true;
}
/**
* 获取中断类型
*/
public InterruptType getInterruptType(String conversationId) {
RunState state = runs.get(conversationId);
return state != null ? state.interruptType : null;
}
/**
* 清除中断状态
*/
public void clearInterruptState(String conversationId) {
RunState state = runs.get(conversationId);
if (state != null) {
state.interruptType = null;
}
}
/**
* 检查是否有排队消息
*/
public boolean hasQueuedMessage(String conversationId) {
RunState state = runs.get(conversationId);
return state != null && !state.messageQueue.isEmpty();
}
/**
* 获取当前排队消息数量
*/
public int getQueueSize(String conversationId) {
RunState state = runs.get(conversationId);
return state != null ? state.messageQueue.size() : 0;
}
// ===== Approval idempotency =====
/**
* 尝试标记一个 approval ID 为已广播。如果已经广播过则返回 false(幂等去重)。
*/
public boolean markApprovalBroadcasted(String conversationId, String pendingId) {
RunState state = runs.get(conversationId);
if (state == null) return false;
return state.broadcastedApprovalIds.add(pendingId);
}
// ===== Utility =====
private static String safe(String s) {
return s != null ? s : "";
}
/**
* 将 buffer 裁剪到 MAX_BUFFER_SIZE 以内。
* 策略:将连续的同类型 delta 事件合并为一条(拼接 delta 文本,保留完整内容但减少条目数)。
* 如果合并后仍超限,丢弃最早的 thinking_delta(thinking 对重连恢复不是关键内容)。
* 必须在 state.lock 内调用。
*/
private static void trimBuffer(List