package vip.mate.channel.web;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import vip.mate.common.result.R;
import vip.mate.agent.AgentService;
import vip.mate.agent.model.AgentEntity;
import vip.mate.approval.ApprovalWorkflowService;
import vip.mate.approval.MetadataDecision;
import vip.mate.approval.PendingApproval;
import vip.mate.approval.ResolveOutcome;
import vip.mate.memory.event.ConversationCompletionPublisher;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.MessageContentPart;
import vip.mate.workspace.conversation.model.MessageEntity;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.io.IOException;
import reactor.core.Disposable;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Web 渠道聊天接口
* 提供 SSE 流式对话和同步对话能力
*
* @author MateClaw Team
*/
@Tag(name = "Web聊天")
@Slf4j
@RestController
@RequestMapping("/api/v1/chat")
@RequiredArgsConstructor
public class ChatController {
private final AgentService agentService;
private final ConversationService conversationService;
private final ApprovalWorkflowService approvalService;
private final ChatStreamTracker streamTracker;
private final ObjectMapper objectMapper;
private final ConversationCompletionPublisher completionPublisher;
private final Path uploadRoot = Paths.get("data", "chat-uploads");
// 使用虚拟线程池处理 SSE(Java 17+ 兼容,Java 21 可用 Executors.newVirtualThreadPerTaskExecutor())
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
/**
* SSE 流式对话(支持断线重连)
*
* 正常请求:保存用户消息,启动 Flux 生产者,通过 StreamTracker 广播事件。
* 重连请求(reconnect=true):附着到仍在运行的流,回放已缓冲事件后接收实时增量。
*/
@Operation(summary = "结构化 SSE 流式对话(支持重连)")
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chatStream(
@RequestBody ChatStreamRequest request,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
Authentication auth) {
String conversationId = request.getConversationId() != null ? request.getConversationId() : "default";
// SSE 超时设为 10 分钟,覆盖 servlet 默认的 30s,避免长回答被中断
// RFC-058 PR-1: Utf8SseEmitter 显式声明 charset=UTF-8,防止中文在 Windows 中文 Chrome / 部分代理处乱码
SseEmitter emitter = new Utf8SseEmitter(10 * 60 * 1000L);
// ---- 分支 A:断线重连 ----
if (Boolean.TRUE.equals(request.getReconnect())) {
String reconnectUser = auth != null ? auth.getName() : "anonymous";
log.info("SSE reconnect: conversationId={}, user={}", conversationId, reconnectUser);
// 校验会话归属
if (!conversationService.isConversationOwner(conversationId, reconnectUser)) {
try {
sendEvent(emitter, "error", Map.of("message", "无权访问该会话"));
} catch (IOException e) {
log.warn("SSE reconnect auth error send failed: {}", e.getMessage());
}
emitter.complete();
return emitter;
}
registerEmitterCallbacks(emitter, conversationId);
// Issue #17 — distinguish "stream truly completed on this node"
// from "stream is running on another node (multi-node deployment
// without sticky session)". They look identical from attach()'s
// boolean return, but the user-facing remediation is different.
boolean existsLocally = streamTracker.streamExistsOnThisNode(conversationId);
long lastEventId = request.getLastEventId() == null ? 0L : request.getLastEventId();
boolean attached = streamTracker.attach(conversationId, emitter, lastEventId);
if (!attached) {
try {
if (existsLocally) {
// RunState exists here but is done — stream finished normally
sendEvent(emitter, "done", Map.of("status", "completed"));
} else {
// No RunState on this node. Either:
// (a) The stream finished long ago and was cleaned up, OR
// (b) Multi-node deployment routed this reconnect to a
// DIFFERENT node than the originating one. The CE
// build assumes single-instance (see ChatStreamTracker
// class javadoc and rfc-054 §0); LB must be configured
// for sticky session by conversationId.
// We can't tell (a) from (b) at this layer, so emit an
// explicit code the front-end can surface to operators.
log.info("SSE reconnect: no RunState locally for conversationId={} — " +
"either completed-and-cleaned or running on another node", conversationId);
sendEvent(emitter, "done", Map.of(
"status", "stream_not_local",
"message", "Stream is not active on this node. " +
"If you're running a multi-node deployment, " +
"verify the load balancer is configured for sticky " +
"session by conversationId. See deploy/multi-node-deployment.md."
));
}
} catch (IOException e) {
log.warn("SSE reconnect done send error: {}", e.getMessage());
}
emitter.complete();
}
return emitter;
}
// ---- 分支 B:正常请求 ----
Long agentId = request.getAgentId();
String message = request.getMessage() != null ? request.getMessage() : "";
if (auth == null) {
try {
sendEvent(emitter, "error", Map.of("message", "未登录,请先登录"));
} catch (IOException e) {
log.warn("SSE auth error send failed: {}", e.getMessage());
}
emitter.complete();
return emitter;
}
String username = auth.getName();
log.info("SSE chat: agentId={}, conversationId={}, user={}", agentId, conversationId, username);
// ---- Workspace 边界校验:确保 agent 属于当前 workspace ----
if (agentId != null) {
AgentEntity agent = agentService.getAgent(agentId);
if (agent != null && agent.getWorkspaceId() != null) {
long wsId = workspaceId != null ? workspaceId : 1L;
if (!agent.getWorkspaceId().equals(wsId)) {
log.warn("Chat workspace mismatch: agent {} belongs to workspace {}, request workspace {}",
agentId, agent.getWorkspaceId(), wsId);
try {
sendEvent(emitter, "error", Map.of("message", "Agent 不属于当前工作区"));
sendEvent(emitter, "done", Map.of("status", "completed"));
} catch (IOException e) {
log.warn("SSE workspace error send failed: {}", e.getMessage());
}
emitter.complete();
return emitter;
}
}
}
// ---- 审批命令拦截:/approve、/deny 走 SSE 流式 replay ----
String normalizedMsg = message.trim().toLowerCase();
boolean isApprovalCommand = "/approve".equals(normalizedMsg) || "approve".equals(normalizedMsg);
boolean isDenyCommand = "/deny".equals(normalizedMsg) || "deny".equals(normalizedMsg);
if (isApprovalCommand || isDenyCommand) {
PendingApproval pending = approvalService.findPendingByConversation(conversationId);
if (pending == null) {
try {
sendEvent(emitter, "error", Map.of("message", "当前没有待审批的工具调用"));
sendEvent(emitter, "done", Map.of("status", "completed"));
} catch (IOException e) { /* ignore */ }
emitter.complete();
return emitter;
}
// deny: workflow.resolve handles DB + metadata + memory atomically.
if (isDenyCommand) {
ResolveOutcome denyOutcome = approvalService.resolve(pending.getPendingId(), username, "denied");
conversationService.removeApprovalPlaceholders(conversationId);
log.info("[Approval-Stream] User {} denied pending {} for conversation {} (dbSynced={}, msgRewritten={})",
username, pending.getPendingId(), conversationId,
denyOutcome.dbSynced(), denyOutcome.messagesRewritten());
}
// approve: atomic resolveAndConsume; workflow handles DB + metadata + memory.
PendingApproval consumed = null;
if (isApprovalCommand) {
ResolveOutcome consumeOutcome = approvalService.resolveAndConsume(pending.getPendingId(), username);
if (consumeOutcome.isAlreadyResolved()) {
try {
sendEvent(emitter, "error", Map.of("message", "审批记录已过期或已被处理"));
sendEvent(emitter, "done", Map.of("status", "completed"));
} catch (IOException e2) { /* ignore */ }
emitter.complete();
return emitter;
}
consumed = consumeOutcome.consumedSnapshot();
// Clear residual approval placeholder messages so the LLM context for
// replay doesn't include "[Awaiting approval]" text artifacts.
conversationService.removeApprovalPlaceholders(conversationId);
log.info("[Approval-Stream] User {} approved pending {} for conversation {} (msgRewritten={})",
username, consumed.getPendingId(), conversationId, consumeOutcome.messagesRewritten());
}
final PendingApproval finalConsumed = consumed;
final String decision = isApprovalCommand ? "approved" : "denied";
streamTracker.register(conversationId);
Long approvalAgentId = parseLongOrNull(pending.getAgentId());
streamTracker.bindRunMeta(conversationId, approvalAgentId, username);
registerEmitterCallbacks(emitter, conversationId);
streamTracker.attach(conversationId, emitter);
AtomicBoolean approvalEmitterDone = new AtomicBoolean(false);
sseExecutor.execute(() -> {
StreamAccumulator accumulator = new StreamAccumulator();
AtomicBoolean finalized = new AtomicBoolean(false);
try {
// 广播 approval_resolved 事件
broadcastEvent(conversationId, "tool_approval_resolved", Map.of(
"pendingId", pending.getPendingId(),
"decision", decision,
"toolName", pending.getToolName(),
"timestamp", System.currentTimeMillis()
));
if ("denied".equals(decision)) {
String denyMsg = "用户拒绝执行工具 " + pending.getToolName();
MessageEntity savedAssistant = conversationService.saveMessage(conversationId, "assistant", denyMsg);
broadcastEvent(conversationId, "message_start", Map.of("role", "assistant"));
broadcastEvent(conversationId, "content_delta", Map.of("delta", denyMsg));
broadcastEvent(conversationId, "message_complete", Map.of("status", "completed"));
broadcastEvent(conversationId, "done", buildDonePayload(
conversationId, "completed", savedAssistant, 0, 0,
isAssistantPersisted(savedAssistant),
conversationService.getMessageCount(conversationId)));
// deny 是正常 turn 终结,用户可能在 awaiting_approval 阶段排了消息
ChatStreamTracker.CompletionResult denyCr = streamTracker.completeAndConsumeIfLast(conversationId);
if (denyCr.allDone() && denyCr.queuedInput() != null) {
startQueuedMessage(conversationId, emitter, approvalEmitterDone, denyCr.queuedInput(), username);
} else {
completeEmitterQuietly(emitter, approvalEmitterDone);
}
return;
}
// approved: 使用已原子消费的记录触发 replay 流
if (finalConsumed == null) {
broadcastEvent(conversationId, "error", Map.of("message", "审批记录已被消费"));
broadcastEvent(conversationId, "done", Map.of("status", "completed"));
// 审批记录被另一个请求消费,但用户可能在等待期间排了消息
ChatStreamTracker.CompletionResult consumedNullCr = streamTracker.completeAndConsumeIfLast(conversationId);
if (consumedNullCr.allDone() && consumedNullCr.queuedInput() != null) {
startQueuedMessage(conversationId, emitter, approvalEmitterDone, consumedNullCr.queuedInput(), username);
} else {
completeEmitterQuietly(emitter, approvalEmitterDone);
}
return;
}
Long replayAgentId = finalConsumed.getAgentId() != null
? Long.parseLong(finalConsumed.getAgentId()) : agentId;
broadcastEvent(conversationId, "message_start", Map.of("role", "assistant"));
// 不含工具名的中性 prompt(对齐 IM 渠道,防止 fallthrough 时误导 LLM)
String replayPrompt = "继续执行已批准的工具调用。";
streamTracker.incrementFlux(conversationId);
// RFC-063r §2.12: prefer the persisted Memento snapshot
// (covers cross-restart approval where the original channel
// is gone) and fall back to a fresh web-origin
// ChatOrigin when none was captured.
vip.mate.agent.context.ChatOrigin replayOrigin =
approvalService.restoreChatOrigin(finalConsumed.getChatOrigin());
if (replayOrigin == vip.mate.agent.context.ChatOrigin.EMPTY) {
replayOrigin = vip.mate.agent.context.ChatOrigin.web(
conversationId, username, workspaceId, null);
}
Disposable disposable = agentService.chatWithReplayStream(
replayAgentId, replayPrompt, conversationId, finalConsumed.getToolCallPayload(), username, replayOrigin)
.doOnNext(delta -> {
if (approvalEmitterDone.get()) return;
try {
accumulator.accept(delta, conversationId);
} catch (Exception e) {
log.warn("SSE replay broadcast error: {}", e.getMessage());
}
})
.doOnComplete(() -> {
if (!finalized.compareAndSet(false, true)) return;
// Force-recycle short-circuit: see main doOnComplete below.
if (streamTracker.isRecycled(conversationId)) {
log.info("SSE replay doOnComplete skipped for force-recycled conversation: {}", conversationId);
try {
conversationService.updateStreamStatus(conversationId, "idle");
} catch (Exception e) {
log.debug("recycled-skip: stream_status reset failed for {}: {}",
conversationId, e.getMessage());
}
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
if (cr.allDone()) {
completeEmitterQuietly(emitter, approvalEmitterDone);
}
return;
}
// Replay can re-trigger an approval (the approved tool
// call may chain into another guarded tool). Derive status the same
// way as the normal stream so awaiting_approval doesn't get masked
// as completed.
boolean replayWasStopped = streamTracker.isStopRequested(conversationId);
ChatStreamTracker.InterruptType replayInterrupt = streamTracker.getInterruptType(conversationId);
boolean replayIsError = accumulator.getContent() != null
&& accumulator.getContent().startsWith("[错误] ");
String persistStatus = derivePersistStatus(
accumulator.isAwaitingApproval(), replayIsError,
replayWasStopped, replayInterrupt);
try {
MessageEntity savedAssistant = null;
List parts = accumulator.toAssistantParts();
String text = accumulator.getContent();
if (!text.isBlank() || !parts.isEmpty()) {
savedAssistant = conversationService.saveMessage(conversationId, "assistant", text, parts,
persistStatus,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson()); // includes toolCalls metadata
} else if (replayWasStopped) {
boolean replayIsFollowup = replayInterrupt == ChatStreamTracker.InterruptType.USER_INTERRUPT_WITH_FOLLOWUP;
savedAssistant = conversationService.saveMessage(conversationId, "assistant",
replayIsFollowup ? "[已中断]" : "[已停止生成]", null, persistStatus);
} else {
savedAssistant = saveEmptyAssistantPlaceholder(
conversationId, persistStatus, accumulator, "SSE replay doOnComplete");
}
broadcastEvent(conversationId, "message_complete", Map.of(
"status", persistStatus,
"hasThinking", !accumulator.getThinking().isBlank(),
"hasContent", !text.isBlank()
));
int msgCount = conversationService.getMessageCount(conversationId);
broadcastEvent(conversationId, "done", buildDonePayload(
conversationId, persistStatus, savedAssistant, 0, 0,
isAssistantPersisted(savedAssistant), msgCount));
} catch (Exception e) {
log.warn("SSE replay complete error: {}", e.getMessage());
} finally {
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
if (cr.allDone()) {
if (cr.queuedInput() != null) {
startQueuedMessage(conversationId, emitter, approvalEmitterDone, cr.queuedInput(), username);
} else {
conversationService.updateStreamStatus(conversationId, "idle");
completeEmitterQuietly(emitter, approvalEmitterDone);
}
}
}
})
.doOnError(e -> {
if (!finalized.compareAndSet(false, true)) return;
// Force-recycle short-circuit: see main doOnComplete below.
if (streamTracker.isRecycled(conversationId)) {
log.info("SSE replay doOnError skipped for force-recycled conversation: {}, cause={}",
conversationId, e.getMessage());
try {
conversationService.updateStreamStatus(conversationId, "idle");
} catch (Exception ex) {
log.debug("recycled-skip: stream_status reset failed for {}: {}",
conversationId, ex.getMessage());
}
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
if (cr.allDone()) {
completeEmitterQuietly(emitter, approvalEmitterDone);
}
return;
}
boolean isUserStop = e instanceof java.util.concurrent.CancellationException
|| (e.getCause() instanceof java.util.concurrent.CancellationException);
ChatStreamTracker.InterruptType replayInterruptType = streamTracker.getInterruptType(conversationId);
boolean replayIsFollowup = replayInterruptType == ChatStreamTracker.InterruptType.USER_INTERRUPT_WITH_FOLLOWUP;
String errStatus = !isUserStop ? "failed"
: replayIsFollowup ? "interrupted" : "stopped";
if (replayIsFollowup) {
log.info("SSE replay stream interrupted for follow-up: conversationId={}", conversationId);
} else if (isUserStop) {
log.info("SSE replay stream stopped by user: conversationId={}", conversationId);
} else {
log.error("SSE replay error: {}", e.getMessage());
}
try {
MessageEntity savedAssistant = null;
List replayParts = accumulator.toAssistantParts();
String replayText = accumulator.getContent();
if (!replayText.isBlank() || !replayParts.isEmpty()) {
String savedText = replayText.isBlank() && isUserStop
? (replayIsFollowup ? "[已中断]" : "[已停止生成]") : replayText;
savedAssistant = conversationService.saveMessage(conversationId, "assistant", savedText, replayParts,
errStatus,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
} else if (isUserStop) {
savedAssistant = conversationService.saveMessage(conversationId, "assistant",
replayIsFollowup ? "[已中断]" : "[已停止生成]", null, errStatus);
} else {
savedAssistant = conversationService.saveMessage(conversationId, "assistant",
"[错误] " + (e.getMessage() != null ? e.getMessage() : "replay error"),
null, "failed");
}
if (replayIsFollowup) {
broadcastEvent(conversationId, "message_complete", Map.of(
"status", "interrupted",
"hasThinking", !accumulator.getThinking().isBlank(),
"hasContent", !replayText.isBlank()
));
broadcastEvent(conversationId, "turn_interrupted", Map.of(
"conversationId", conversationId,
"hasQueuedMessage", streamTracker.hasQueuedMessage(conversationId)
));
} else if (isUserStop) {
broadcastEvent(conversationId, "message_complete", Map.of(
"status", "stopped",
"hasThinking", !accumulator.getThinking().isBlank(),
"hasContent", !replayText.isBlank()
));
int stoppedMsgCount = conversationService.getMessageCount(conversationId);
broadcastEvent(conversationId, "done", buildDonePayload(
conversationId, "stopped", savedAssistant, 0, 0,
isAssistantPersisted(savedAssistant), stoppedMsgCount));
} else {
broadcastEvent(conversationId, "error", buildErrorPayload(
conversationId,
e.getMessage() != null ? e.getMessage() : "replay error",
savedAssistant));
}
} catch (Exception ex) {
log.warn("SSE replay error finalize failed: {}", ex.getMessage());
}
streamTracker.clearInterruptState(conversationId);
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
if (cr.allDone()) {
if (cr.queuedInput() != null) {
startQueuedMessage(conversationId, emitter, approvalEmitterDone, cr.queuedInput(), username);
} else {
conversationService.updateStreamStatus(conversationId, "idle");
completeEmitterQuietly(emitter, approvalEmitterDone);
}
}
})
.subscribe(
chunk -> { },
err -> log.debug("SSE replay subscription terminated: {}", err.getMessage()),
() -> log.debug("SSE replay subscription completed: conversationId={}", conversationId));
streamTracker.setDisposable(conversationId, disposable);
streamTracker.setEmergencySaveCallback(conversationId,
() -> emergencySaveAccumulator(conversationId, accumulator));
} catch (Exception e) {
log.error("SSE approval replay setup error: {}", e.getMessage());
streamTracker.complete(conversationId);
completeEmitterQuietly(emitter, approvalEmitterDone);
}
});
return emitter;
}
// ---- 正常请求:注册流状态并附着首个订阅者 ----
streamTracker.register(conversationId);
streamTracker.bindRunMeta(conversationId, agentId, username);
registerEmitterCallbacks(emitter, conversationId);
streamTracker.attach(conversationId, emitter);
// Per-emitter "the SSE channel is open and you should reset any
// pending placeholder UI". Sent directly to the emitter rather than
// broadcast so reconnecting subscribers don't see a duplicate marker
// for an already-open conversation.
try {
sendEvent(emitter, "stream_started", Map.of(
"conversationId", conversationId,
"timestamp", System.currentTimeMillis()
));
} catch (IOException e) {
log.debug("Failed to send stream_started event for {}: {}", conversationId, e.getMessage());
}
// 标记 emitter 是否已结束,防止 Flux 回调再次写入已关闭的 emitter
AtomicBoolean emitterDone = new AtomicBoolean(false);
sseExecutor.execute(() -> {
StreamAccumulator accumulator = new StreamAccumulator();
AtomicBoolean finalized = new AtomicBoolean(false);
try {
conversationService.getOrCreateConversation(conversationId, agentId, username, workspaceId);
List requestParts = normalizeRequestParts(request);
String promptText = buildPromptText(message, requestParts);
conversationService.saveMessage(conversationId, "user", message, requestParts);
conversationService.updateStreamStatus(conversationId, "running");
broadcastEvent(conversationId, "session", Map.of(
"conversationId", conversationId,
"agentId", agentId
));
broadcastEvent(conversationId, "message_start", Map.of(
"role", "assistant"
));
streamTracker.incrementFlux(conversationId);
// RFC-063r §2.5: web entry — null channelId / no ChannelTarget;
// tools that need a workspace path read it from the agent (origin
// is enriched with workspaceBasePath in StateGraph buildInitialState).
vip.mate.agent.context.ChatOrigin webOrigin =
vip.mate.agent.context.ChatOrigin.web(conversationId, username, workspaceId, null);
Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel(), webOrigin)
.doOnNext(delta -> {
if (emitterDone.get()) return;
try {
accumulator.accept(delta, conversationId);
} catch (Exception e) {
log.warn("SSE broadcast error: {}", e.getMessage());
}
})
.doOnComplete(() -> {
if (!finalized.compareAndSet(false, true)) return;
// Force-recycle: the recycle path already wrote a
// "[已被用户中止]" placeholder (or the partial
// content via emergencySave). The agent's flux may
// have completed the same millisecond — skip its
// save + broadcast so we don't append a duplicate
// assistant row below the placeholder. Cleanup
// still runs so queue draining + emitter close
// happen normally.
if (streamTracker.isRecycled(conversationId)) {
log.info("SSE doOnComplete skipped for force-recycled conversation: {}", conversationId);
streamTracker.clearInterruptState(conversationId);
// Defensive: keep DB stream_status consistent with the
// "this turn is over" reality even when we skip the
// save. Force-recycle's controller path already wrote
// 'idle' for the recycled run, so this is normally a
// no-op — but if a register() ever fails to clear the
// marker (e.g. a different turn snuck through), this
// prevents the row leaking at 'running' across refresh.
try {
conversationService.updateStreamStatus(conversationId, "idle");
} catch (Exception e) {
log.debug("recycled-skip: stream_status reset failed for {}: {}",
conversationId, e.getMessage());
}
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
if (cr.allDone()) {
completeEmitterQuietly(emitter, emitterDone);
}
return;
}
// 区分四种完成语义:
// 1. 正常完成(stopRequested=false)→ completed
// 2. 用户主动停止 → stopped
// 3. 用户中断后续跑(interrupt-with-followup)→ interrupted
// 4. LLM 客户端错误 / typed error → error
// (NodeStreamingChatHelper 把 typed error 序列化为 "[错误] "
// 前缀的文本作为 content_delta 注入,accumulator 不区分,
// 这里靠前缀识别。打标 status='error' 后,BaseAgent
// 的 history sanitization 阶段会跳过这类消息,避免下次
// prompt 被污染——DeepSeek thinking mode 缺
// reasoning_content 立即 400,Claude 不接受 assistant
// prefill 也 400,二者循环复制错误。)
boolean wasStopped = streamTracker.isStopRequested(conversationId);
ChatStreamTracker.InterruptType interruptType = streamTracker.getInterruptType(conversationId);
boolean isInterruptFollowup = interruptType == ChatStreamTracker.InterruptType.USER_INTERRUPT_WITH_FOLLOWUP;
boolean isError = accumulator.getContent() != null
&& accumulator.getContent().startsWith("[错误] ");
String persistStatus = derivePersistStatus(
accumulator.isAwaitingApproval(), isError, wasStopped, interruptType);
try {
MessageEntity savedAssistant = null;
List assistantParts = accumulator.toAssistantParts();
String assistantText = accumulator.getContent();
if (!assistantText.isBlank() || !assistantParts.isEmpty()) {
String savedText = assistantText.isBlank() && wasStopped
? (isInterruptFollowup ? "[已中断]" : "[已停止生成]") : assistantText;
savedAssistant = conversationService.saveMessage(conversationId, "assistant", savedText, assistantParts,
persistStatus,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
} else if (wasStopped) {
savedAssistant = conversationService.saveMessage(conversationId, "assistant",
isInterruptFollowup ? "[已中断]" : "[已停止生成]", null, persistStatus);
} else {
savedAssistant = saveEmptyAssistantPlaceholder(
conversationId, persistStatus, accumulator, "SSE doOnComplete");
}
// 发布对话完成事件(仅正常完成时;停止/中断/错误均不触发记忆提取)
// RFC-049 follow-up: also skip on isError — error turns persist
// garbage like "[错误] Bad request..." as the assistant reply,
// which would pollute the memory extraction pipeline if propagated.
if (!wasStopped && !isError) {
completionPublisher.publish(agentId, conversationId, message, assistantText, "web");
}
if (isInterruptFollowup) {
broadcastEvent(conversationId, "message_complete", Map.of(
"status", "interrupted",
"hasThinking", !accumulator.getThinking().isBlank(),
"hasContent", !assistantText.isBlank()
));
broadcastEvent(conversationId, "turn_interrupted", Map.of(
"conversationId", conversationId,
"hasQueuedMessage", streamTracker.hasQueuedMessage(conversationId)
));
} else {
broadcastEvent(conversationId, "message_complete", Map.of(
"status", persistStatus,
"hasThinking", !accumulator.getThinking().isBlank(),
"hasContent", !assistantText.isBlank()
));
int msgCount = conversationService.getMessageCount(conversationId);
broadcastEvent(conversationId, "done", buildDonePayload(
conversationId, persistStatus, savedAssistant,
accumulator.getPromptTokens(), accumulator.getCompletionTokens(),
isAssistantPersisted(savedAssistant), msgCount));
}
} catch (Exception e) {
log.warn("SSE complete error: {}", e.getMessage());
} finally {
streamTracker.clearInterruptState(conversationId);
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
if (cr.allDone()) {
// RFC follow-up (2026-04-27): the previous guard
// cr.queuedInput() != null && (isInterruptFollowup || !wasStopped)
// dropped legitimate queued messages when the user stopped
// the running turn and then sent a new message via the
// enqueue path (not the interrupt-with-followup path) —
// wasStopped=true + isInterruptFollowup=false made the
// guard false, the consumed queuedInput was discarded,
// and the user's new message vanished. The other 4 sites
// in this controller already use the simpler "if queued,
// run it" condition; align with them. If the user
// genuinely doesn't want continuation, no message would
// have been in messageQueue to begin with.
if (cr.queuedInput() != null) {
startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username);
} else {
conversationService.updateStreamStatus(conversationId, "idle");
// 延迟关闭 emitter,确保最后的事件都已发送
sseExecutor.execute(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException ignored) {}
completeEmitterQuietly(emitter, emitterDone);
});
}
} else {
log.info("Original stream completed but replay still active, " +
"keeping SSE emitter alive: conversationId={}", conversationId);
}
}
})
.doOnCancel(() -> {
boolean wasFirst = finalized.compareAndSet(false, true);
log.info("SSE doOnCancel fired: conversationId={}, wasFirst={}", conversationId, wasFirst);
if (!wasFirst) return;
// Force-recycle short-circuit: see doOnComplete above.
if (streamTracker.isRecycled(conversationId)) {
log.info("SSE doOnCancel skipped for force-recycled conversation: {}", conversationId);
streamTracker.clearInterruptState(conversationId);
try {
conversationService.updateStreamStatus(conversationId, "idle");
} catch (Exception e) {
log.debug("recycled-skip: stream_status reset failed for {}: {}",
conversationId, e.getMessage());
}
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
if (cr.allDone()) {
completeEmitterQuietly(emitter, emitterDone);
}
return;
}
// 区分用户主动停止和 interrupt-with-followup
ChatStreamTracker.InterruptType interruptType = streamTracker.getInterruptType(conversationId);
boolean isInterruptFollowup = interruptType == ChatStreamTracker.InterruptType.USER_INTERRUPT_WITH_FOLLOWUP;
String status = isInterruptFollowup ? "interrupted" : "stopped";
log.info("SSE stream cancelled ({}): conversationId={}", status, conversationId);
try {
MessageEntity savedAssistant = null;
List assistantParts = accumulator.toAssistantParts();
String assistantText = accumulator.getContent();
if (!assistantText.isBlank() || !assistantParts.isEmpty()) {
String savedText = assistantText.isBlank()
? (isInterruptFollowup ? "[已中断]" : "[已停止生成]") : assistantText;
savedAssistant = conversationService.saveMessage(conversationId, "assistant", savedText, assistantParts,
status,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
} else {
savedAssistant = conversationService.saveMessage(conversationId, "assistant",
isInterruptFollowup ? "[已中断]" : "[已停止生成]", null, status);
}
if (isInterruptFollowup) {
broadcastEvent(conversationId, "message_complete", Map.of(
"status", "interrupted",
"hasThinking", !accumulator.getThinking().isBlank(),
"hasContent", !assistantText.isBlank()
));
broadcastEvent(conversationId, "turn_interrupted", Map.of(
"conversationId", conversationId,
"hasQueuedMessage", streamTracker.hasQueuedMessage(conversationId)
));
} else {
broadcastEvent(conversationId, "message_complete", Map.of(
"status", "stopped",
"hasThinking", !accumulator.getThinking().isBlank(),
"hasContent", !assistantText.isBlank()
));
int stoppedMsgCount = conversationService.getMessageCount(conversationId);
broadcastEvent(conversationId, "done", buildDonePayload(
conversationId, "stopped", savedAssistant, 0, 0,
isAssistantPersisted(savedAssistant), stoppedMsgCount));
}
} catch (Exception e) {
log.warn("SSE stop finalize error: {}", e.getMessage());
} finally {
streamTracker.clearInterruptState(conversationId);
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
if (cr.allDone()) {
if (cr.queuedInput() != null) {
// 无论中断类型,都消费排队消息(修复 Disposable 不可用时队列被丢弃的 bug)
startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username);
} else {
conversationService.updateStreamStatus(conversationId, "idle");
completeEmitterQuietly(emitter, emitterDone);
}
}
}
})
.doOnError(e -> {
boolean wasFirst = finalized.compareAndSet(false, true);
if (!wasFirst) {
log.info("SSE doOnError skipped (finalized by doOnCancel): conversationId={}", conversationId);
return;
}
// Force-recycle short-circuit: see doOnComplete above.
if (streamTracker.isRecycled(conversationId)) {
log.info("SSE doOnError skipped for force-recycled conversation: {}, cause={}",
conversationId, e.getMessage());
streamTracker.clearInterruptState(conversationId);
try {
conversationService.updateStreamStatus(conversationId, "idle");
} catch (Exception ex) {
log.debug("recycled-skip: stream_status reset failed for {}: {}",
conversationId, ex.getMessage());
}
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
if (cr.allDone()) {
completeEmitterQuietly(emitter, emitterDone);
}
return;
}
// CancellationException = 用户主动停止或中断续跑
boolean isUserStop = e instanceof java.util.concurrent.CancellationException
|| (e.getCause() instanceof java.util.concurrent.CancellationException);
ChatStreamTracker.InterruptType interruptType = streamTracker.getInterruptType(conversationId);
boolean isInterruptFollowup = interruptType == ChatStreamTracker.InterruptType.USER_INTERRUPT_WITH_FOLLOWUP;
// 三态:interrupted > stopped > failed
String status = !isUserStop ? "failed"
: isInterruptFollowup ? "interrupted" : "stopped";
if (isInterruptFollowup) {
log.info("SSE stream interrupted for follow-up (CancellationException): conversationId={}", conversationId);
} else if (isUserStop) {
log.info("SSE stream stopped by user (CancellationException): conversationId={}", conversationId);
} else if (isClientDisconnect(e)) {
log.warn("SSE client disconnected: conversationId={}, cause={}", conversationId, e.getMessage());
} else {
log.error("SSE stream error: conversationId={}, cause={}", conversationId, e.getMessage());
}
try {
List assistantParts = accumulator.toAssistantParts();
String assistantText = accumulator.getContent();
log.info("SSE doOnError saving: conversationId={}, status={}, textLen={}, partsCount={}",
conversationId, status, assistantText.length(), assistantParts.size());
String errorMsg = e.getMessage() != null ? e.getMessage() : "unknown error";
MessageEntity savedAssistant = null;
if (!assistantText.isBlank() || !assistantParts.isEmpty()) {
String savedText = assistantText.isBlank() && isUserStop
? (isInterruptFollowup ? "[已中断]" : "[已停止生成]") : assistantText;
savedAssistant = conversationService.saveMessage(conversationId, "assistant", savedText, assistantParts,
status,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
accumulator.getRuntimeModelName(),
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
} else if (isUserStop) {
savedAssistant = conversationService.saveMessage(conversationId, "assistant",
isInterruptFollowup ? "[已中断]" : "[已停止生成]", null, status);
} else {
savedAssistant = conversationService.saveMessage(conversationId, "assistant", "[错误] " + errorMsg, null, "failed");
}
if (isInterruptFollowup) {
broadcastEvent(conversationId, "message_complete", Map.of(
"status", "interrupted",
"hasThinking", !accumulator.getThinking().isBlank(),
"hasContent", !assistantText.isBlank()
));
broadcastEvent(conversationId, "turn_interrupted", Map.of(
"conversationId", conversationId,
"hasQueuedMessage", streamTracker.hasQueuedMessage(conversationId)
));
} else if (isUserStop) {
broadcastEvent(conversationId, "message_complete", Map.of(
"status", "stopped",
"hasThinking", !accumulator.getThinking().isBlank(),
"hasContent", !assistantText.isBlank()
));
int stoppedMsgCount = conversationService.getMessageCount(conversationId);
broadcastEvent(conversationId, "done", buildDonePayload(
conversationId, "stopped", savedAssistant, 0, 0,
isAssistantPersisted(savedAssistant), stoppedMsgCount));
} else {
broadcastEvent(conversationId, "error", buildErrorPayload(conversationId, errorMsg, savedAssistant));
}
} catch (Exception ioException) {
log.error("SSE doOnError save/broadcast failed: conversationId={}, error={}",
conversationId, ioException.getMessage(), ioException);
}
streamTracker.clearInterruptState(conversationId);
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
log.info("SSE doOnError cleanup: conversationId={}, allDone={}, isInterruptFollowup={}, hasQueued={}",
conversationId, cr.allDone(), isInterruptFollowup, cr.queuedInput() != null);
if (cr.allDone()) {
// RFC follow-up (2026-04-27): the previous guard
// cr.queuedInput()!=null && !(isUserStop && !isInterruptFollowup)
// tried to suppress continuation when the user "explicitly
// stopped" without an interrupt-with-followup. But the
// frontend's enqueue path doesn't set interruptType — it
// just calls requestStop + offers to messageQueue. From the
// server's POV that's "isUserStop=true, isInterruptFollowup=
// false, queue has content", which the guard mis-classified
// as "abort" and silently dropped the user's freshly-typed
// follow-up. Whoever puts a message in messageQueue means it
// — just run it. Aligns with doOnComplete and the 4 other
// queue-launch sites in this controller.
if (cr.queuedInput() != null) {
startQueuedMessage(conversationId, emitter, emitterDone, cr.queuedInput(), username);
} else {
conversationService.updateStreamStatus(conversationId, "idle");
completeEmitterQuietly(emitter, emitterDone);
}
}
})
.subscribe(
chunk -> { },
error -> log.debug("SSE stream subscription terminated with error: {}", error.getMessage()),
() -> log.debug("SSE stream subscription completed: conversationId={}", conversationId));
// 将 Disposable 注册到 StreamTracker,以便 stop 端点可以取消它
streamTracker.setDisposable(conversationId, disposable);
// JVM 关闭时优雅落盘:避免 mvn spring-boot:run 重启 / SIGTERM 把
// 进行中 turn 的 assistant 消息丢失(doOnError 来不及在 Hikari 关闭前执行)
streamTracker.setEmergencySaveCallback(conversationId,
() -> emergencySaveAccumulator(conversationId, accumulator));
} catch (Exception e) {
log.error("SSE setup error: {}", e.getMessage());
try {
broadcastEvent(conversationId, "error", Map.of("message", e.getMessage() != null ? e.getMessage() : "unknown error"));
} catch (Exception ioException) {
log.warn("SSE setup failure event broadcast error: {}", ioException.getMessage());
}
streamTracker.complete(conversationId);
conversationService.updateStreamStatus(conversationId, "idle");
completeEmitterQuietly(emitter, emitterDone);
}
});
return emitter;
}
/**
* 停止指定会话的流式生成。
* 取消 Flux 订阅(底层 HTTP 连接也会随之关闭),已生成的部分内容以 stopped 状态入库。
*
* Stop 同时清理所有未 resolve 的 pending approval:当 LLM 在一个 turn 里连发了
* 多个需要审批的工具调用、用户在中间 Stop 时,这些 pending 会一直留在 in-memory
* pendingMap 里。下次刷新页面时 frontend 的 hydrate 链路(`getPendingApprovals` API
* + 消息 metadata 里的 `pendingApproval` 字段)会反复弹出"允许 xxx 执行?"banner。
* Stop 端点现在 deny 所有 pending、同步 update 受影响 message 的 metadata,并广播
* tool_approval_resolved 让前端实时清理 UI。
*/
@Operation(summary = "停止流式生成")
@PostMapping("/{conversationId}/stop")
public R