feat(chat): realtime sync for external channel conversations

This commit is contained in:
matevip 2026-04-17 08:25:32 +08:00
parent 8fbe30d7ac
commit b35c29767c
4 changed files with 185 additions and 35 deletions

View File

@ -9,6 +9,7 @@ import vip.mate.approval.PendingApproval;
import vip.mate.channel.model.ChannelEntity;
import vip.mate.channel.notification.ApprovalNotificationService;
import vip.mate.channel.service.ChannelService;
import vip.mate.channel.web.ChatStreamTracker;
import org.springframework.context.ApplicationEventPublisher;
import vip.mate.memory.event.ConversationCompletedEvent;
import vip.mate.tts.TtsService;
@ -50,6 +51,7 @@ public class ChannelMessageRouter {
private final ApplicationEventPublisher eventPublisher;
private final TtsService ttsService;
private final ObjectMapper objectMapper;
private final ChatStreamTracker streamTracker;
/** 队列条目:封装消息及其路由上下文 */
private record QueueEntry(ChannelMessage message, ChannelAdapter adapter, ChannelEntity channelEntity) {}
@ -93,7 +95,8 @@ public class ChannelMessageRouter {
ApprovalNotificationService approvalNotificationService,
ApplicationEventPublisher eventPublisher,
TtsService ttsService,
ObjectMapper objectMapper) {
ObjectMapper objectMapper,
ChatStreamTracker streamTracker) {
this.agentService = agentService;
this.conversationService = conversationService;
this.channelService = channelService;
@ -103,6 +106,7 @@ public class ChannelMessageRouter {
this.eventPublisher = eventPublisher;
this.ttsService = ttsService;
this.objectMapper = objectMapper;
this.streamTracker = streamTracker;
}
// ==================== 防抖辅助类 ====================
@ -417,31 +421,58 @@ public class ChannelMessageRouter {
// 构建 prompt语音输入时注入场景提示词
String promptText = buildPromptFromParts(message.getContent(), parts, message.getInputMode());
// 流式路径渠道实现了 StreamingChannelAdapter 则委托渠道渲染流式事件
if (adapter instanceof StreamingChannelAdapter streamingAdapter) {
processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity);
} else {
// 同步路径直接获取完整回复
String reply = agentService.chat(agentId, promptText, conversationId);
// 检查 chat 过程中是否产生了审批 pending
PendingApproval newPending = approvalService.findPendingByConversation(conversationId);
if (newPending != null) {
// 有审批需求不保存 LLM 的审批占位回复到 DB直接从 pending 元数据构建通知
String approvalNotice = buildApprovalNotice(newPending);
adapter.renderAndSend(replyTarget, approvalNotice);
log.info("[{}] Approval triggered during chat, sent notice (NOT saved to DB): tool={}",
adapter.getChannelType(), newPending.getToolName());
// 注册到 ChatStreamTracker graph 节点广播的事件phase / content_delta / tool_call_*
// 能被 ChatConsole observer 订阅到不注册 broadcast() 会因 state==null 短路丢弃
// 同步 DB stream_status 让侧栏列表可以识别"该渠道对话正在运行"从而触发 selectConversation reconnect 分支
streamTracker.register(conversationId);
streamTracker.incrementFlux(conversationId);
conversationService.updateStreamStatus(conversationId, "running");
try {
// 流式路径渠道实现了 StreamingChannelAdapter 则委托渠道渲染流式事件
if (adapter instanceof StreamingChannelAdapter streamingAdapter) {
processWithStreaming(message, streamingAdapter, conversationId, agentId, promptText, channelEntity);
} else {
// 正常回复保存并发送
conversationService.saveMessage(conversationId, "assistant", reply);
publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply);
adapter.renderAndSend(replyTarget, reply);
log.info("[{}] Reply sent to {}: {}chars",
adapter.getChannelType(), replyTarget, reply.length());
// 同步路径直接获取完整回复
String reply = agentService.chat(agentId, promptText, conversationId);
// 语音回复异步 TTS 合成并追加发送先文本后语音不阻塞
maybeGenerateVoiceReply(message, adapter, replyTarget, conversationId, reply, channelEntity);
// 检查 chat 过程中是否产生了审批 pending
PendingApproval newPending = approvalService.findPendingByConversation(conversationId);
if (newPending != null) {
// 有审批需求不保存 LLM 的审批占位回复到 DB直接从 pending 元数据构建通知
String approvalNotice = buildApprovalNotice(newPending);
adapter.renderAndSend(replyTarget, approvalNotice);
log.info("[{}] Approval triggered during chat, sent notice (NOT saved to DB): tool={}",
adapter.getChannelType(), newPending.getToolName());
} else {
// 正常回复保存并发送
conversationService.saveMessage(conversationId, "assistant", reply);
publishConversationCompletedEvent(agentId, conversationId, message.getContent(), reply);
adapter.renderAndSend(replyTarget, reply);
log.info("[{}] Reply sent to {}: {}chars",
adapter.getChannelType(), replyTarget, reply.length());
// observer 推送完整的 assistant 消息以便前端一次性渲染为气泡
// content_delta 事件可能没开的同步路径下作为兜底
streamTracker.broadcastObject(conversationId, "message_complete", Map.of(
"conversationId", conversationId,
"content", reply,
"timestamp", System.currentTimeMillis()));
// 语音回复异步 TTS 合成并追加发送先文本后语音不阻塞
maybeGenerateVoiceReply(message, adapter, replyTarget, conversationId, reply, channelEntity);
}
}
} finally {
// 广播 done 事件让 observer 前端收尾
streamTracker.broadcastObject(conversationId, "done", Map.of(
"conversationId", conversationId,
"status", "completed",
"timestamp", System.currentTimeMillis()));
streamTracker.completeAndConsumeIfLast(conversationId);
try {
conversationService.updateStreamStatus(conversationId, "idle");
} catch (Exception e) {
log.debug("Failed to reset stream_status for {}: {}", conversationId, e.getMessage());
}
}

View File

@ -1118,6 +1118,17 @@ export function useChat(options: UseChatOptions): UseChatReturn {
const convId = streamConversationId
const assistantId = currentAssistantId.value
// 仅在前端确实在生成时才触发停止(含 SSE 接收中 / reconnect 中 / 审批等待中)。
// 否则只是"旁观者"身份,不能把对方(渠道用户)的 agent run 也一起杀掉。
const activelyStreaming = isGenerating.value
|| streamPhase.value === 'reconnecting'
|| streamPhase.value === 'awaiting_approval'
if (!activelyStreaming) {
// 没有真正在流 → 什么都不做,让调用方直接走 resetForNewConversation
return
}
// 先取消排队消息
messageQueue.clear()
@ -1190,8 +1201,22 @@ export function useChat(options: UseChatOptions): UseChatReturn {
errorFired = false
phaseInfo.value = null
// 创建 assistant 占位消息用于接收重连后的流数据
resetCurrentTurnState()
// 清理尾部的空 assistant 消息(来自上一轮被误杀的 run 留下的空壳,或 placeholder 遗留),
// 避免与即将重连产生的 streaming 气泡共存形成"重复两条"假象。
while (messages.value.length > 0) {
const tail = messages.value[messages.value.length - 1]
if (tail && tail.role === 'assistant'
&& tail.conversationId === conversationId
&& !tail.content
&& (!tail.contentParts || tail.contentParts.length === 0)) {
messages.value.pop()
} else {
break
}
}
const assistantMessage = createAssistantMessage('', conversationId)
;(assistantMessage as any)._turnId = activeTurnId
currentAssistantId.value = assistantMessage.id as string

View File

@ -174,6 +174,16 @@ function mergeAssistantMessages(localMsg: Message, fetchedMsg: Message): Message
* - assistant richness
* - fetched assistant lagging snapshot
*/
/**
* "客户端临时 id"UUID / id
* id Long
*/
function isClientId(id: any): boolean {
if (id === null || id === undefined) return true
const s = String(id)
return s === '' || !/^\d+$/.test(s)
}
export function reconcileMessages(local: Message[], fetched: Message[]): Message[] {
if (!local.length) return fetched
if (!fetched.length) return local
@ -186,19 +196,40 @@ export function reconcileMessages(local: Message[], fetched: Message[]): Message
const matchedLocalIds = new Set<string>()
const result: Message[] = []
// 收集未被 id 匹配过的本地 assistant通常是流式产生的 client-uuid placeholder
// 供 fetched 端新 assistant"认领"它们的 timeline避免两条并排。
const unclaimedLocalAssistants: Message[] = []
for (const lm of local) {
if (lm.role === 'assistant' && isClientId(lm.id)) {
unclaimedLocalAssistants.push(lm)
}
}
for (const fm of fetched) {
const fid = String(fm.id)
const lm = localMap.get(fid)
if (!lm) {
result.push(fm)
} else if (fm.role !== 'assistant') {
result.push(fm)
if (lm) {
if (fm.role !== 'assistant') {
result.push(fm)
} else {
// assistant 消息:不要整条覆盖,合并 fetched 的持久化字段与 local 的 richer timeline
result.push(mergeAssistantMessages(lm, fm))
}
matchedLocalIds.add(fid)
continue
}
// fetched 里这条本地没有
if (fm.role === 'assistant' && unclaimedLocalAssistants.length > 0) {
// 尝试"认领"本地一个 client-uuid placeholder
// 取队首(最早的未认领),合并 richness 后采用 fetched 的持久化 id/时间。
// 这消除了"本地流式气泡"+"刚落库的 DB assistant"同时存在的重复。
const claimed = unclaimedLocalAssistants.shift()!
matchedLocalIds.add(String(claimed.id))
result.push(mergeAssistantMessages(claimed, fm))
} else {
// assistant 消息:不要整条覆盖,合并 fetched 的持久化字段与 local 的 richer timeline
result.push(mergeAssistantMessages(lm, fm))
matchedLocalIds.add(fid)
result.push(fm)
}
}

View File

@ -684,6 +684,44 @@ function handleKeyboardShortcuts(e: KeyboardEvent) {
}
}
// ChatConsole WeChat/DingTalk/
// F5 /
let activityPollTimer: number | null = null
const ACTIVITY_POLL_MS = 4000
async function pollActivity() {
//
if (typeof document !== 'undefined' && document.hidden) return
try {
await loadConversations()
} catch {
//
}
// +
if (currentConversationId.value && !isGenerating.value && streamPhase.value !== 'awaiting_approval') {
const cid = currentConversationId.value
try {
const statusRes: any = await conversationApi.getStatus(cid)
if (currentConversationId.value !== cid) return
const running = statusRes?.data?.streamStatus === 'running'
if (running) {
//
// 1. DB user ""
// assistant content_delta
// 2. content_delta assistant
await refreshCurrentConversationMessages(cid)
if (currentConversationId.value !== cid || isGenerating.value) return
await reconnectStream(cid)
} else {
// DB user / assistant
await refreshCurrentConversationMessages(cid)
}
} catch {
//
}
}
}
onMounted(async () => {
document.addEventListener('keydown', handleKeyboardShortcuts)
document.addEventListener('click', handleCodeCopy)
@ -696,6 +734,7 @@ onMounted(async () => {
mediumQuery.addEventListener('change', handleConvMediumChange)
await Promise.all([loadAgents(), loadModelState(), loadConversations()])
await hydrateStateFromRoute()
activityPollTimer = window.setInterval(pollActivity, ACTIVITY_POLL_MS)
})
onBeforeUnmount(() => {
@ -704,6 +743,10 @@ onBeforeUnmount(() => {
disposeECharts()
mobileQuery?.removeEventListener('change', handleMobileChange)
mediumQuery?.removeEventListener('change', handleConvMediumChange)
if (activityPollTimer !== null) {
clearInterval(activityPollTimer)
activityPollTimer = null
}
stopChatGeneration()
// ObjectURL
revokeAllPreviewUrls()
@ -834,7 +877,11 @@ function syncRouteState() {
async function selectConversation(conv: Conversation) {
if (isMobile.value) convPanelOpen.value = false
resetStreamingState()
// reset stop observer
const switchingAway = currentConversationId.value !== conv.conversationId
if (switchingAway) {
resetStreamingState()
}
currentConversationId.value = conv.conversationId
selectedAgentId.value = conv.agentId || selectedAgentId.value
const requestedConvId = conv.conversationId
@ -842,7 +889,10 @@ async function selectConversation(conv: Conversation) {
const res: any = await conversationApi.listMessages(requestedConvId)
// Stale guardawait
if (currentConversationId.value !== requestedConvId) return
messages.value = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg))
// SSE
if (switchingAway || !isGenerating.value) {
messages.value = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg))
}
// Hydrate pending approvals
try {
@ -875,7 +925,20 @@ async function selectConversation(conv: Conversation) {
// hydration 使
}
if (currentConversationId.value === requestedConvId && conv.streamStatus === 'running') {
// SSE
// - streamStatus==='running'
// -
let shouldReconnect = conv.streamStatus === 'running'
if (!shouldReconnect) {
try {
const statusRes: any = await conversationApi.getStatus(requestedConvId)
if (currentConversationId.value !== requestedConvId) return
shouldReconnect = statusRes?.data?.streamStatus === 'running'
} catch {
//
}
}
if (currentConversationId.value === requestedConvId && shouldReconnect) {
await reconnectStream(requestedConvId)
}
} catch (e) {