fix(chat): channel conversation sync + running indicator

- ChannelMessageRouter: include assistantMessageId in message_complete
  and done broadcasts so ChatConsole observers can reconcile the
  streaming placeholder to the persisted DB row by id instead of
  falling back to the FIFO 'claim' heuristic (which occasionally
  dropped the assistant bubble on external channel conversations).
  Capture the id from ConversationService.saveMessage in both the
  sync agentService.chat path and the streaming processWithStreaming
  path; switch to HashMap since Map.of rejects null values when save
  is skipped (e.g. under approval).

- ChatConsole: add a 'running' indicator on the sidebar so users can
  tell which conversations have an in-flight agent run. Pulsing amber
  dot on the channel icon (both expanded and collapsed modes) plus a
  '生成中…' / 'Generating…' pill in expanded mode.

- ChatConsole: don't cancel the previous conversation's streaming run
  when switching conversations — let it keep running in the background
  and reconcile when the user comes back.
This commit is contained in:
matevip 2026-04-17 10:03:56 +08:00
parent b35c29767c
commit a3e6724cf4
2 changed files with 120 additions and 18 deletions

View File

@ -15,6 +15,7 @@ import vip.mate.memory.event.ConversationCompletedEvent;
import vip.mate.tts.TtsService; import vip.mate.tts.TtsService;
import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.MessageContentPart; 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.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; 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.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.*; import java.util.concurrent.*;
@ -427,10 +429,15 @@ public class ChannelMessageRouter {
streamTracker.register(conversationId); streamTracker.register(conversationId);
streamTracker.incrementFlux(conversationId); streamTracker.incrementFlux(conversationId);
conversationService.updateStreamStatus(conversationId, "running"); 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 { try {
// 流式路径:渠道实现了 StreamingChannelAdapter 则委托渠道渲染流式事件 // 流式路径:渠道实现了 StreamingChannelAdapter 则委托渠道渲染流式事件
if (adapter instanceof StreamingChannelAdapter streamingAdapter) { if (adapter instanceof StreamingChannelAdapter streamingAdapter) {
processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity); savedAssistantId = processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity);
} else { } else {
// 同步路径:直接获取完整回复 // 同步路径:直接获取完整回复
String reply = agentService.chat(agentId, promptText, conversationId); String reply = agentService.chat(agentId, promptText, conversationId);
@ -445,7 +452,8 @@ public class ChannelMessageRouter {
adapter.getChannelType(), newPending.getToolName()); adapter.getChannelType(), newPending.getToolName());
} else { } 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); publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply);
adapter.renderAndSend(replyTarget, reply); adapter.renderAndSend(replyTarget, reply);
log.info("[{}] Reply sent to {}: {}chars", log.info("[{}] Reply sent to {}: {}chars",
@ -453,21 +461,29 @@ public class ChannelMessageRouter {
// 给 observer 推送完整的 assistant 消息以便前端一次性渲染为气泡 // 给 observer 推送完整的 assistant 消息以便前端一次性渲染为气泡
// (在 content_delta 事件可能没开的同步路径下作为兜底) // (在 content_delta 事件可能没开的同步路径下作为兜底)
streamTracker.broadcastObject(conversationId, "message_complete", Map.of( Map<String, Object> msgCompletePayload = new HashMap<>();
"conversationId", conversationId, msgCompletePayload.put("conversationId", conversationId);
"content", reply, msgCompletePayload.put("content", reply);
"timestamp", System.currentTimeMillis())); if (savedAssistantId != null) {
msgCompletePayload.put("assistantMessageId", savedAssistantId);
}
msgCompletePayload.put("timestamp", System.currentTimeMillis());
streamTracker.broadcastObject(conversationId, "message_complete", msgCompletePayload);
// 语音回复:异步 TTS 合成并追加发送(先文本后语音,不阻塞) // 语音回复:异步 TTS 合成并追加发送(先文本后语音,不阻塞)
maybeGenerateVoiceReply(message, adapter, replyTarget, conversationId, reply, channelEntity); maybeGenerateVoiceReply(message, adapter, replyTarget, conversationId, reply, channelEntity);
} }
} }
} finally { } finally {
// 广播 done 事件让 observer 前端收尾 // 广播 done 事件让 observer 前端收尾(HashMap 允许 null 值缺失,Map.of 不允许)
streamTracker.broadcastObject(conversationId, "done", Map.of( Map<String, Object> donePayload = new HashMap<>();
"conversationId", conversationId, donePayload.put("conversationId", conversationId);
"status", "completed", donePayload.put("status", "completed");
"timestamp", System.currentTimeMillis())); if (savedAssistantId != null) {
donePayload.put("assistantMessageId", savedAssistantId);
}
donePayload.put("timestamp", System.currentTimeMillis());
streamTracker.broadcastObject(conversationId, "done", donePayload);
streamTracker.completeAndConsumeIfLast(conversationId); streamTracker.completeAndConsumeIfLast(conversationId);
try { try {
conversationService.updateStreamStatus(conversationId, "idle"); conversationService.updateStreamStatus(conversationId, "idle");
@ -499,7 +515,7 @@ public class ChannelMessageRouter {
* - StreamingChannelAdapter 负责渲染(AI Card / 卡片更新 / 文本累积等) * - StreamingChannelAdapter 负责渲染(AI Card / 卡片更新 / 文本累积等)
* - Router 负责后续的审批检查、消息持久化、事件发布 * - Router 负责后续的审批检查、消息持久化、事件发布
*/ */
private void processWithStreaming(ChannelMessage message, StreamingChannelAdapter streamingAdapter, private Long processWithStreaming(ChannelMessage message, StreamingChannelAdapter streamingAdapter,
String conversationId, Long agentId, String promptText, String conversationId, Long agentId, String promptText,
ChannelEntity channelEntity) { ChannelEntity channelEntity) {
String channelType = streamingAdapter.getChannelType(); String channelType = streamingAdapter.getChannelType();
@ -521,7 +537,7 @@ public class ChannelMessageRouter {
log.info("[{}] Approval triggered during streaming (NOT saved to DB): tool={}", log.info("[{}] Approval triggered during streaming (NOT saved to DB): tool={}",
channelType, newPending.getToolName()); channelType, newPending.getToolName());
} else if (finalContent != null && !finalContent.isBlank()) { } else if (finalContent != null && !finalContent.isBlank()) {
conversationService.saveMessage(conversationId, "assistant", finalContent); MessageEntity saved = conversationService.saveMessage(conversationId, "assistant", finalContent);
publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent); publishConversationCompletedEvent(agentId, conversationId, promptText, finalContent);
log.info("[{}] Streaming completed: contentLen={}", channelType, finalContent.length()); log.info("[{}] Streaming completed: contentLen={}", channelType, finalContent.length());
@ -531,6 +547,7 @@ public class ChannelMessageRouter {
maybeGenerateVoiceReply(message, streamingAdapter, replyTarget, maybeGenerateVoiceReply(message, streamingAdapter, replyTarget,
conversationId, finalContent, channelEntity); conversationId, finalContent, channelEntity);
} }
return saved != null ? saved.getId() : null;
} }
} catch (Exception e) { } catch (Exception e) {
@ -543,6 +560,7 @@ public class ChannelMessageRouter {
log.error("[{}] Failed to send streaming error message: {}", channelType, sendErr.getMessage()); log.error("[{}] Failed to send streaming error message: {}", channelType, sendErr.getMessage());
} }
} }
return null;
} }
// ==================== 审批重放 ==================== // ==================== 审批重放 ====================

View File

@ -63,11 +63,19 @@
v-for="conv in group.items" v-for="conv in group.items"
:key="conv.conversationId" :key="conv.conversationId"
class="conv-item" class="conv-item"
:class="{ active: currentConversationId === conv.conversationId }" :class="{
active: currentConversationId === conv.conversationId,
'is-running': conv.streamStatus === 'running',
}"
@click="selectConversation(conv)" @click="selectConversation(conv)"
> >
<div class="conv-icon"> <div class="conv-icon">
<img :src="channelIconUrl(conv.source)" width="14" height="14" alt="" /> <img :src="channelIconUrl(conv.source)" width="14" height="14" alt="" />
<span
v-if="conv.streamStatus === 'running'"
class="conv-running-dot"
:title="$t('chat.streamGenerating')"
></span>
</div> </div>
<div v-if="!convPanelCollapsed || isMobile" class="conv-info"> <div v-if="!convPanelCollapsed || isMobile" class="conv-info">
<input <input
@ -80,7 +88,17 @@
@click.stop @click.stop
ref="renameInputRef" ref="renameInputRef"
/> />
<div v-else class="conv-title" @dblclick.stop="startRename(conv)">{{ conv.title }}</div> <div v-else class="conv-title" @dblclick.stop="startRename(conv)">
<span>{{ conv.title }}</span>
<span
v-if="conv.streamStatus === 'running'"
class="conv-running-badge"
:title="$t('chat.streamGenerating')"
>
<span class="conv-running-badge-pulse"></span>
{{ $t('chat.streamGenerating') }}
</span>
</div>
<div class="conv-meta"> <div class="conv-meta">
<span>{{ $t('chat.messages', { count: conv.messageCount }) }}</span> <span>{{ $t('chat.messages', { count: conv.messageCount }) }}</span>
<span class="conv-dot">·</span> <span class="conv-dot">·</span>
@ -877,10 +895,14 @@ function syncRouteState() {
async function selectConversation(conv: Conversation) { async function selectConversation(conv: Conversation) {
if (isMobile.value) convPanelOpen.value = false 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 const switchingAway = currentConversationId.value !== conv.conversationId
if (switchingAway) { if (switchingAway) {
resetStreamingState() resetForNewConversation()
} }
currentConversationId.value = conv.conversationId currentConversationId.value = conv.conversationId
selectedAgentId.value = conv.agentId || selectedAgentId.value selectedAgentId.value = conv.agentId || selectedAgentId.value
@ -1740,12 +1762,64 @@ function handleCodeCopy(e: MouseEvent) {
.conv-icon { .conv-icon {
color: var(--mc-text-tertiary); color: var(--mc-text-tertiary);
flex-shrink: 0; flex-shrink: 0;
position: relative;
} }
.conv-item.active .conv-icon { .conv-item.active .conv-icon {
color: var(--mc-primary); 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 { .conv-info {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
@ -1755,9 +1829,19 @@ function handleCodeCopy(e: MouseEvent) {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: var(--mc-text-primary); 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; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1 1 auto;
} }
.conv-item.active .conv-title { .conv-item.active .conv-title {