fix(chat): preserve refresh history and thinking order

This commit is contained in:
matevip 2026-04-11 00:16:33 +08:00
parent 0020f43584
commit f28ad8d9d4
2 changed files with 181 additions and 69 deletions

View File

@ -23,6 +23,7 @@ import vip.mate.approval.PendingApproval;
import vip.mate.memory.event.ConversationCompletedEvent;
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;
@ -211,11 +212,13 @@ public class ChatController {
if ("denied".equals(decision)) {
String denyMsg = "用户拒绝执行工具 " + pending.getToolName();
conversationService.saveMessage(conversationId, "assistant", denyMsg);
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", Map.of("status", "completed"));
broadcastEvent(conversationId, "done", buildDonePayload(
conversationId, "completed", savedAssistant, 0, 0, true,
conversationService.getMessageCount(conversationId)));
// deny 是正常 turn 终结用户可能在 awaiting_approval 阶段排了消息
ChatStreamTracker.CompletionResult denyCr = streamTracker.completeAndConsumeIfLast(conversationId);
if (denyCr.allDone() && denyCr.queuedInput() != null) {
@ -262,10 +265,11 @@ public class ChatController {
.doOnComplete(() -> {
if (!finalized.compareAndSet(false, true)) return;
try {
MessageEntity savedAssistant = null;
List<MessageContentPart> parts = accumulator.toAssistantParts();
String text = accumulator.getContent();
if (!text.isBlank() || !parts.isEmpty()) {
conversationService.saveMessage(conversationId, "assistant", text, parts,
savedAssistant = conversationService.saveMessage(conversationId, "assistant", text, parts,
"completed",
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
@ -279,12 +283,8 @@ public class ChatController {
"hasContent", !text.isBlank()
));
int msgCount = conversationService.getMessageCount(conversationId);
broadcastEvent(conversationId, "done", Map.of(
"conversationId", conversationId,
"status", "completed",
"persisted", true,
"messageCount", msgCount
));
broadcastEvent(conversationId, "done", buildDonePayload(
conversationId, "completed", savedAssistant, 0, 0, true, msgCount));
} catch (Exception e) {
log.warn("SSE replay complete error: {}", e.getMessage());
} finally {
@ -318,12 +318,13 @@ public class ChatController {
}
try {
MessageEntity savedAssistant = null;
List<MessageContentPart> replayParts = accumulator.toAssistantParts();
String replayText = accumulator.getContent();
if (!replayText.isBlank() || !replayParts.isEmpty()) {
String savedText = replayText.isBlank() && isUserStop
? (replayIsFollowup ? "[已中断]" : "[已停止生成]") : replayText;
conversationService.saveMessage(conversationId, "assistant", savedText, replayParts,
savedAssistant = conversationService.saveMessage(conversationId, "assistant", savedText, replayParts,
errStatus,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
@ -331,7 +332,7 @@ public class ChatController {
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
} else if (isUserStop) {
conversationService.saveMessage(conversationId, "assistant",
savedAssistant = conversationService.saveMessage(conversationId, "assistant",
replayIsFollowup ? "[已中断]" : "[已停止生成]", null, errStatus);
}
@ -352,15 +353,13 @@ public class ChatController {
"hasContent", !replayText.isBlank()
));
int stoppedMsgCount = conversationService.getMessageCount(conversationId);
broadcastEvent(conversationId, "done", Map.of(
"conversationId", conversationId,
"status", "stopped",
"persisted", true,
"messageCount", stoppedMsgCount
));
broadcastEvent(conversationId, "done", buildDonePayload(
conversationId, "stopped", savedAssistant, 0, 0, true, stoppedMsgCount));
} else {
broadcastEvent(conversationId, "error", Map.of("message",
e.getMessage() != null ? e.getMessage() : "replay error"));
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());
@ -446,12 +445,13 @@ public class ChatController {
persistStatus = isInterruptFollowup ? "interrupted" : "stopped";
}
try {
MessageEntity savedAssistant = null;
List<MessageContentPart> assistantParts = accumulator.toAssistantParts();
String assistantText = accumulator.getContent();
if (!assistantText.isBlank() || !assistantParts.isEmpty()) {
String savedText = assistantText.isBlank() && wasStopped
? (isInterruptFollowup ? "[已中断]" : "[已停止生成]") : assistantText;
conversationService.saveMessage(conversationId, "assistant", savedText, assistantParts,
savedAssistant = conversationService.saveMessage(conversationId, "assistant", savedText, assistantParts,
persistStatus,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
@ -459,7 +459,7 @@ public class ChatController {
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
} else if (wasStopped) {
conversationService.saveMessage(conversationId, "assistant",
savedAssistant = conversationService.saveMessage(conversationId, "assistant",
isInterruptFollowup ? "[已中断]" : "[已停止生成]", null, persistStatus);
}
// 发布对话完成事件仅正常完成时停止/中断不触发记忆提取
@ -490,14 +490,9 @@ public class ChatController {
"hasContent", !assistantText.isBlank()
));
int msgCount = conversationService.getMessageCount(conversationId);
broadcastEvent(conversationId, "done", Map.of(
"conversationId", conversationId,
"status", persistStatus,
"promptTokens", accumulator.getPromptTokens(),
"completionTokens", accumulator.getCompletionTokens(),
"persisted", true,
"messageCount", msgCount
));
broadcastEvent(conversationId, "done", buildDonePayload(
conversationId, persistStatus, savedAssistant,
accumulator.getPromptTokens(), accumulator.getCompletionTokens(), true, msgCount));
}
} catch (Exception e) {
log.warn("SSE complete error: {}", e.getMessage());
@ -534,12 +529,13 @@ public class ChatController {
log.info("SSE stream cancelled ({}): conversationId={}", status, conversationId);
try {
MessageEntity savedAssistant = null;
List<MessageContentPart> assistantParts = accumulator.toAssistantParts();
String assistantText = accumulator.getContent();
if (!assistantText.isBlank() || !assistantParts.isEmpty()) {
String savedText = assistantText.isBlank()
? (isInterruptFollowup ? "[已中断]" : "[已停止生成]") : assistantText;
conversationService.saveMessage(conversationId, "assistant", savedText, assistantParts,
savedAssistant = conversationService.saveMessage(conversationId, "assistant", savedText, assistantParts,
status,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
@ -547,7 +543,7 @@ public class ChatController {
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
} else {
conversationService.saveMessage(conversationId, "assistant",
savedAssistant = conversationService.saveMessage(conversationId, "assistant",
isInterruptFollowup ? "[已中断]" : "[已停止生成]", null, status);
}
@ -568,12 +564,8 @@ public class ChatController {
"hasContent", !assistantText.isBlank()
));
int stoppedMsgCount = conversationService.getMessageCount(conversationId);
broadcastEvent(conversationId, "done", Map.of(
"conversationId", conversationId,
"status", "stopped",
"persisted", true,
"messageCount", stoppedMsgCount
));
broadcastEvent(conversationId, "done", buildDonePayload(
conversationId, "stopped", savedAssistant, 0, 0, true, stoppedMsgCount));
}
} catch (Exception e) {
log.warn("SSE stop finalize error: {}", e.getMessage());
@ -623,10 +615,11 @@ public class ChatController {
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;
conversationService.saveMessage(conversationId, "assistant", savedText, assistantParts,
savedAssistant = conversationService.saveMessage(conversationId, "assistant", savedText, assistantParts,
status,
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
@ -634,10 +627,10 @@ public class ChatController {
accumulator.getRuntimeProviderId(),
accumulator.toMetadataJson());
} else if (isUserStop) {
conversationService.saveMessage(conversationId, "assistant",
savedAssistant = conversationService.saveMessage(conversationId, "assistant",
isInterruptFollowup ? "[已中断]" : "[已停止生成]", null, status);
} else {
conversationService.saveMessage(conversationId, "assistant", "[错误] " + errorMsg, null, "failed");
savedAssistant = conversationService.saveMessage(conversationId, "assistant", "[错误] " + errorMsg, null, "failed");
}
if (isInterruptFollowup) {
@ -657,17 +650,10 @@ public class ChatController {
"hasContent", !assistantText.isBlank()
));
int stoppedMsgCount = conversationService.getMessageCount(conversationId);
broadcastEvent(conversationId, "done", Map.of(
"conversationId", conversationId,
"status", "stopped",
"persisted", true,
"messageCount", stoppedMsgCount
));
broadcastEvent(conversationId, "done", buildDonePayload(
conversationId, "stopped", savedAssistant, 0, 0, true, stoppedMsgCount));
} else {
broadcastEvent(conversationId, "error", Map.of(
"message", errorMsg,
"conversationId", conversationId
));
broadcastEvent(conversationId, "error", buildErrorPayload(conversationId, errorMsg, savedAssistant));
}
} catch (Exception ioException) {
log.error("SSE doOnError save/broadcast failed: conversationId={}, error={}",
@ -1011,10 +997,11 @@ public class ChatController {
.doOnComplete(() -> {
if (!finalized.compareAndSet(false, true)) return;
try {
MessageEntity savedAssistant = null;
List<MessageContentPart> parts = accumulator.toAssistantParts();
String text = accumulator.getContent();
if (!text.isBlank() || !parts.isEmpty()) {
conversationService.saveMessage(conversationId, "assistant", text, parts,
savedAssistant = conversationService.saveMessage(conversationId, "assistant", text, parts,
"completed",
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
@ -1027,11 +1014,10 @@ public class ChatController {
"hasThinking", !accumulator.getThinking().isBlank(),
"hasContent", !text.isBlank()
));
broadcastEvent(conversationId, "done", Map.of(
"status", "completed",
"promptTokens", accumulator.getPromptTokens(),
"completionTokens", accumulator.getCompletionTokens()
));
broadcastEvent(conversationId, "done", buildDonePayload(
conversationId, "completed", savedAssistant,
accumulator.getPromptTokens(), accumulator.getCompletionTokens(), true,
conversationService.getMessageCount(conversationId)));
} catch (Exception e) {
log.warn("SSE queued complete error: {}", e.getMessage());
} finally {
@ -1055,10 +1041,11 @@ public class ChatController {
log.error("SSE queued stream error: conversationId={}, cause={}", conversationId, e.getMessage());
// 持久化已累积的 assistant 消息修复原逻辑未保存导致回答丢失
try {
MessageEntity savedAssistant = null;
List<MessageContentPart> parts = accumulator.toAssistantParts();
String text = accumulator.getContent();
if (!text.isBlank() || !parts.isEmpty()) {
conversationService.saveMessage(conversationId, "assistant", text, parts,
savedAssistant = conversationService.saveMessage(conversationId, "assistant", text, parts,
"failed",
accumulator.getPromptTokens(),
accumulator.getCompletionTokens(),
@ -1067,14 +1054,16 @@ public class ChatController {
accumulator.toMetadataJson());
} else {
String errorMsg = e.getMessage() != null ? e.getMessage() : "queued stream error";
conversationService.saveMessage(conversationId, "assistant",
savedAssistant = conversationService.saveMessage(conversationId, "assistant",
"[错误] " + errorMsg, null, "failed");
}
broadcastEvent(conversationId, "error", buildErrorPayload(
conversationId,
e.getMessage() != null ? e.getMessage() : "queued stream error",
savedAssistant));
} catch (Exception saveEx) {
log.error("SSE queued doOnError save failed: {}", saveEx.getMessage());
}
broadcastEvent(conversationId, "error", Map.of(
"message", e.getMessage() != null ? e.getMessage() : "queued stream error"));
ChatStreamTracker.CompletionResult cr = streamTracker.completeAndConsumeIfLast(conversationId);
if (cr.allDone()) {
if (cr.queuedInput() != null) {
@ -1109,6 +1098,32 @@ public class ChatController {
streamTracker.broadcast(conversationId, name, payload);
}
private Map<String, Object> buildDonePayload(String conversationId, String status, MessageEntity savedAssistant,
int promptTokens, int completionTokens,
boolean persisted, Integer messageCount) {
Map<String, Object> payload = new LinkedHashMap<>();
if (conversationId != null && !conversationId.isBlank()) payload.put("conversationId", conversationId);
payload.put("status", status);
if (savedAssistant != null && savedAssistant.getId() != null) {
payload.put("assistantMessageId", savedAssistant.getId());
}
if (promptTokens > 0) payload.put("promptTokens", promptTokens);
if (completionTokens > 0) payload.put("completionTokens", completionTokens);
payload.put("persisted", persisted);
if (messageCount != null) payload.put("messageCount", messageCount);
return payload;
}
private Map<String, Object> buildErrorPayload(String conversationId, String message, MessageEntity savedAssistant) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("message", message);
if (conversationId != null && !conversationId.isBlank()) payload.put("conversationId", conversationId);
if (savedAssistant != null && savedAssistant.getId() != null) {
payload.put("assistantMessageId", savedAssistant.getId());
}
return payload;
}
private List<MessageContentPart> normalizeRequestParts(ChatStreamRequest request) {
if (request.getContentParts() != null && !request.getContentParts().isEmpty()) {
return request.getContentParts();
@ -1232,12 +1247,20 @@ public class ChatController {
private final List<Map<String, Object>> toolCalls = new ArrayList<>();
/** 有序事件时间线 — 前端分段渲染的权威数据源 */
private final List<Map<String, Object>> segments = new ArrayList<>();
private final List<Map<String, Object>> browserActions = new ArrayList<>();
private final List<String> warnings = new ArrayList<>();
private final List<Map<String, Object>> planStepResults = new ArrayList<>();
private int segCounter = 0;
private int promptTokens = 0;
private int completionTokens = 0;
private String runtimeModelName = "";
private String runtimeProviderId = "";
private boolean awaitingApproval = false;
private String currentPhase = "";
private Long planId = null;
private List<String> planSteps = List.of();
private Integer currentPlanStep = null;
private Map<String, Object> pendingApproval = null;
synchronized void accept(AgentService.StreamDelta delta, String conversationId) {
if (delta == null) return;
@ -1254,6 +1277,7 @@ public class ChatController {
if ("phase".equals(delta.eventType())) {
String phase = String.valueOf(delta.eventData().getOrDefault("phase", ""));
if (!phase.isBlank()) {
currentPhase = phase;
streamTracker.updatePhase(conversationId, phase);
// phase 切换时关闭 running content/thinking segment保留边界
finalizeRunningSegments("content", "thinking");
@ -1309,7 +1333,62 @@ public class ChatController {
private void accumulateToolEvent(String eventType, Map<String, Object> data, String conversationId) {
if ("tool_approval_requested".equals(eventType)) {
awaitingApproval = true;
currentPhase = "awaiting_approval";
pendingApproval = new LinkedHashMap<>();
pendingApproval.put("pendingId", data.getOrDefault("pendingId", ""));
pendingApproval.put("toolName", data.getOrDefault("toolName", ""));
pendingApproval.put("arguments", data.getOrDefault("arguments", ""));
pendingApproval.put("reason", data.getOrDefault("reason", ""));
pendingApproval.put("status", "pending_approval");
if (data.containsKey("findings")) pendingApproval.put("findings", data.get("findings"));
if (data.containsKey("maxSeverity")) pendingApproval.put("maxSeverity", data.get("maxSeverity"));
if (data.containsKey("summary")) pendingApproval.put("summary", data.get("summary"));
streamTracker.updatePhase(conversationId, "awaiting_approval");
} else if ("tool_approval_resolved".equals(eventType)) {
if (pendingApproval != null) {
pendingApproval.put("status",
"approved".equals(String.valueOf(data.getOrDefault("decision", ""))) ? "approved" : "denied");
}
} else if ("plan_created".equals(eventType)) {
Object rawPlanId = data.get("planId");
if (rawPlanId instanceof Number n) {
planId = n.longValue();
} else if (rawPlanId != null) {
try { planId = Long.valueOf(String.valueOf(rawPlanId)); } catch (Exception ignored) {}
}
Object steps = data.get("steps");
if (steps instanceof List<?> list) {
planSteps = list.stream().map(String::valueOf).toList();
planStepResults.clear();
for (int i = 0; i < planSteps.size(); i++) {
planStepResults.add(null);
}
}
currentPlanStep = 0;
} else if ("plan_step_started".equals(eventType)) {
Object idx = data.get("index");
if (idx instanceof Number n) {
currentPlanStep = n.intValue();
}
} else if ("plan_step_completed".equals(eventType)) {
Object idx = data.get("index");
if (idx instanceof Number n) {
int index = n.intValue();
currentPlanStep = index;
ensurePlanStepCapacity(index + 1);
Map<String, Object> stepResult = new LinkedHashMap<>();
stepResult.put("result", data.getOrDefault("result", ""));
stepResult.put("status", "completed");
planStepResults.set(index, stepResult);
}
} else if ("browser_action".equals(eventType)) {
browserActions.add(new LinkedHashMap<>(data));
} else if ("warning".equals(eventType)) {
String warning = String.valueOf(data.getOrDefault("message",
data.getOrDefault("delta", "")));
if (!warning.isBlank()) {
warnings.add(warning);
}
} else if ("tool_call_started".equals(eventType)) {
// toolCalls兼容
Map<String, Object> tc = new LinkedHashMap<>();
@ -1349,6 +1428,12 @@ public class ChatController {
}
}
private void ensurePlanStepCapacity(int size) {
while (planStepResults.size() < size) {
planStepResults.add(null);
}
}
// ==================== Segment helpers ====================
private Map<String, Object> newSegment(String type) {
@ -1430,6 +1515,28 @@ public class ChatController {
if (!segments.isEmpty()) {
metadata.put("segments", segments);
}
if (!currentPhase.isBlank()) {
metadata.put("currentPhase", currentPhase);
}
if (planId != null || !planSteps.isEmpty() || currentPlanStep != null) {
Map<String, Object> plan = new LinkedHashMap<>();
if (planId != null) plan.put("planId", planId);
if (!planSteps.isEmpty()) plan.put("steps", planSteps);
if (currentPlanStep != null) plan.put("currentStep", currentPlanStep);
if (planStepResults.stream().anyMatch(java.util.Objects::nonNull)) {
plan.put("stepResults", planStepResults);
}
metadata.put("plan", plan);
}
if (pendingApproval != null && !pendingApproval.isEmpty()) {
metadata.put("pendingApproval", pendingApproval);
}
if (!browserActions.isEmpty()) {
metadata.put("browserActions", browserActions);
}
if (!warnings.isEmpty()) {
metadata.put("warnings", warnings);
}
return objectMapper.writeValueAsString(metadata);
} catch (Exception e) {
log.warn("Failed to serialize metadata: {}", e.getMessage());

View File

@ -594,19 +594,12 @@ const segments = computed<MessageSegment[]>(() => {
if (props.message.role !== 'assistant') return []
const meta = parsedMetadata.value
// DEBUG:
if (meta?.segments) {
console.debug('[MessageBubble] segments found:', meta.segments.length, 'first:', meta.segments[0]?.type)
} else {
console.debug('[MessageBubble] NO segments in metadata, keys:', Object.keys(meta || {}))
}
// 使 metadata.segments
// 线 thinkingtoolscontent
if (meta?.segments && Array.isArray(meta.segments) && meta.segments.length > 0
&& typeof meta.segments[0] === 'object' && meta.segments[0]?.type) {
// segments thinking contentParts thinking
const segs = [...meta.segments] as MessageSegment[]
// segments thinking contentParts thinking
const hasThinking = segs.some(s => s.type === 'thinking')
if (!hasThinking) {
const thinkingPart = props.message.contentParts?.find(p => p.type === 'thinking')
@ -614,6 +607,18 @@ const segments = computed<MessageSegment[]>(() => {
segs.unshift({ id: 'th-fb', type: 'thinking', status: 'completed', thinkingText: thinkingPart.text })
}
}
// thinking content content
// thinking 线
const thinkingIndices = segs
.map((seg, index) => seg.type === 'thinking' ? index : -1)
.filter(index => index >= 0)
const firstNonThinkingIdx = segs.findIndex((seg: MessageSegment) => seg.type !== 'thinking')
if (thinkingIndices.length === 1 && firstNonThinkingIdx >= 0 && thinkingIndices[0] > firstNonThinkingIdx) {
const [thinkingSeg] = segs.splice(thinkingIndices[0], 1)
segs.splice(0, 0, thinkingSeg)
}
return segs
}