diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java index d4be0191..d334833a 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -15,6 +15,7 @@ import vip.mate.memory.event.ConversationCompletedEvent; import vip.mate.tts.TtsService; import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.model.MessageContentPart; +import vip.mate.workspace.conversation.model.MessageEntity; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -22,6 +23,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.*; @@ -427,10 +429,15 @@ public class ChannelMessageRouter { streamTracker.register(conversationId); streamTracker.incrementFlux(conversationId); conversationService.updateStreamStatus(conversationId, "running"); + // 捕获已保存 assistant 的 DB id,供 finally 的 done 事件带出来 —— 前端 useChat.done 处理器 + // 依赖 data.assistantMessageId 把本地流式 placeholder 的 client-uuid 升级为 DB id,这样 + // 紧接着 refreshCurrentConversationMessages 的 reconcile 能按 id 干净匹配,不会走"认领"兜底 + // 导致气泡丢失。 + Long savedAssistantId = null; try { // 流式路径:渠道实现了 StreamingChannelAdapter 则委托渠道渲染流式事件 if (adapter instanceof StreamingChannelAdapter streamingAdapter) { - processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity); + savedAssistantId = processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity); } else { // 同步路径:直接获取完整回复 String reply = agentService.chat(agentId, promptText, conversationId); @@ -445,7 +452,8 @@ public class ChannelMessageRouter { adapter.getChannelType(), newPending.getToolName()); } else { // 正常回复:保存并发送 - conversationService.saveMessage(conversationId, "assistant", reply); + MessageEntity saved = conversationService.saveMessage(conversationId, "assistant", reply); + savedAssistantId = saved != null ? saved.getId() : null; publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply); adapter.renderAndSend(replyTarget, reply); log.info("[{}] Reply sent to {}: {}chars", @@ -453,21 +461,29 @@ public class ChannelMessageRouter { // 给 observer 推送完整的 assistant 消息以便前端一次性渲染为气泡 // (在 content_delta 事件可能没开的同步路径下作为兜底) - streamTracker.broadcastObject(conversationId, "message_complete", Map.of( - "conversationId", conversationId, - "content", reply, - "timestamp", System.currentTimeMillis())); + Map msgCompletePayload = new HashMap<>(); + msgCompletePayload.put("conversationId", conversationId); + msgCompletePayload.put("content", reply); + if (savedAssistantId != null) { + msgCompletePayload.put("assistantMessageId", savedAssistantId); + } + msgCompletePayload.put("timestamp", System.currentTimeMillis()); + streamTracker.broadcastObject(conversationId, "message_complete", msgCompletePayload); // 语音回复:异步 TTS 合成并追加发送(先文本后语音,不阻塞) maybeGenerateVoiceReply(message, adapter, replyTarget, conversationId, reply, channelEntity); } } } finally { - // 广播 done 事件让 observer 前端收尾 - streamTracker.broadcastObject(conversationId, "done", Map.of( - "conversationId", conversationId, - "status", "completed", - "timestamp", System.currentTimeMillis())); + // 广播 done 事件让 observer 前端收尾(HashMap 允许 null 值缺失,Map.of 不允许) + Map donePayload = new HashMap<>(); + donePayload.put("conversationId", conversationId); + donePayload.put("status", "completed"); + if (savedAssistantId != null) { + donePayload.put("assistantMessageId", savedAssistantId); + } + donePayload.put("timestamp", System.currentTimeMillis()); + streamTracker.broadcastObject(conversationId, "done", donePayload); streamTracker.completeAndConsumeIfLast(conversationId); try { conversationService.updateStreamStatus(conversationId, "idle"); @@ -499,7 +515,7 @@ public class ChannelMessageRouter { * - StreamingChannelAdapter 负责渲染(AI Card / 卡片更新 / 文本累积等) * - Router 负责后续的审批检查、消息持久化、事件发布 */ - private void processWithStreaming(ChannelMessage message, StreamingChannelAdapter streamingAdapter, + private Long processWithStreaming(ChannelMessage message, StreamingChannelAdapter streamingAdapter, String conversationId, Long agentId, String promptText, ChannelEntity channelEntity) { String channelType = streamingAdapter.getChannelType(); @@ -521,7 +537,7 @@ public class ChannelMessageRouter { log.info("[{}] Approval triggered during streaming (NOT saved to DB): tool={}", channelType, newPending.getToolName()); } else if (finalContent != null && !finalContent.isBlank()) { - conversationService.saveMessage(conversationId, "assistant", finalContent); + MessageEntity saved = conversationService.saveMessage(conversationId, "assistant", finalContent); publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent); log.info("[{}] Streaming completed: contentLen={}", channelType, finalContent.length()); @@ -531,6 +547,7 @@ public class ChannelMessageRouter { maybeGenerateVoiceReply(message, streamingAdapter, replyTarget, conversationId, finalContent, channelEntity); } + return saved != null ? saved.getId() : null; } } catch (Exception e) { @@ -543,6 +560,7 @@ public class ChannelMessageRouter { log.error("[{}] Failed to send streaming error message: {}", channelType, sendErr.getMessage()); } } + return null; } // ==================== 审批重放 ==================== diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index f70663fb..676f192d 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -63,11 +63,19 @@ v-for="conv in group.items" :key="conv.conversationId" class="conv-item" - :class="{ active: currentConversationId === conv.conversationId }" + :class="{ + active: currentConversationId === conv.conversationId, + 'is-running': conv.streamStatus === 'running', + }" @click="selectConversation(conv)" >
+
-
{{ conv.title }}
+
+ {{ conv.title }} + + + {{ $t('chat.streamGenerating') }} + +
{{ $t('chat.messages', { count: conv.messageCount }) }} · @@ -877,10 +895,14 @@ function syncRouteState() { async function selectConversation(conv: Conversation) { if (isMobile.value) convPanelOpen.value = false - // 切换到不同会话时才 reset(含 stop 旧流);点同一个会话时只刷新状态,避免误杀正在跑的 observer 流 + // 切换到不同会话:只清理本地 UI/SSE(resetForNewConversation 会 stream.disconnect + 清变量), + // 但不 POST /chat/{A}/stop —— 让 A 的后台 agent run 跑到完成。 + // 用户之后回到 A:pollActivity / selectConversation 的 /status 探测会自动 reconnect 接回实时流; + // 若 A 已完成,refreshCurrentConversationMessages 会从 DB 拉完整结果。 + // 点同一个会话则完全不 reset,避免打断正在观察的流。 const switchingAway = currentConversationId.value !== conv.conversationId if (switchingAway) { - resetStreamingState() + resetForNewConversation() } currentConversationId.value = conv.conversationId selectedAgentId.value = conv.agentId || selectedAgentId.value @@ -1740,12 +1762,64 @@ function handleCodeCopy(e: MouseEvent) { .conv-icon { color: var(--mc-text-tertiary); flex-shrink: 0; + position: relative; } .conv-item.active .conv-icon { color: var(--mc-primary); } +/* 正在执行:图标右上角脉冲小点(折叠与展开态均可见) */ +.conv-running-dot { + position: absolute; + top: -2px; + right: -2px; + width: 7px; + height: 7px; + border-radius: 50%; + background: #fbbf24; + box-shadow: 0 0 4px rgba(251, 191, 36, 0.6), 0 0 0 2px var(--mc-bg-primary, #fff); + animation: pulse-dot 1.2s infinite; + pointer-events: none; +} + +.conv-item.is-running { + background: color-mix(in srgb, #fbbf24 8%, transparent); +} + +.conv-item.is-running:hover { + background: color-mix(in srgb, #fbbf24 14%, var(--mc-bg-sunken)); +} + +.conv-item.is-running.active { + background: var(--mc-primary-bg); +} + +/* 展开态:标题右侧"生成中..."小徽章 */ +.conv-running-badge { + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + font-size: 10px; + font-weight: 500; + color: #b45309; + background: rgba(251, 191, 36, 0.15); + border: 1px solid rgba(251, 191, 36, 0.3); + padding: 1px 6px 1px 5px; + border-radius: 10px; + line-height: 1.3; + white-space: nowrap; +} + +.conv-running-badge-pulse { + width: 6px; + height: 6px; + border-radius: 50%; + background: #f59e0b; + animation: pulse-dot 1.2s infinite; +} + .conv-info { flex: 1; overflow: hidden; @@ -1755,9 +1829,19 @@ function handleCodeCopy(e: MouseEvent) { font-size: 13px; font-weight: 500; color: var(--mc-text-primary); - white-space: nowrap; + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +/* 标题文本本身承担省略号;flex 父级上的 overflow:hidden 会阻止 ellipsis 正常工作 */ +.conv-title > span:first-child { overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1 1 auto; } .conv-item.active .conv-title {