修复会话丢失bug

This commit is contained in:
537yaha
2026-03-31 14:01:31 +08:00
parent a60ea15148
commit 25c7e54648
8 changed files with 192 additions and 97 deletions
View File
+3 -2
View File
@@ -61,7 +61,7 @@ func (mc *MessageController) CreateMessage(c *gin.Context) {
return
}
_, err := mc.messageService.CreateMessage(service.CreateMessageInput{
msg, err := mc.messageService.CreateMessage(service.CreateMessageInput{
ConversationID: req.ConversationID,
Content: req.Content,
SenderID: req.SenderID,
@@ -89,7 +89,8 @@ func (mc *MessageController) CreateMessage(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"message": "创建消息成功"})
// 返回持久化后的完整消息:客服端/访客端可在发送成功后立即更新 UI,避免仅依赖 WebSocket 时出现「空了要等刷新」
c.JSON(http.StatusOK, msg)
}
// ListMessages 返回指定会话的消息列表。
+6 -5
View File
@@ -96,12 +96,13 @@ func (s *MessageService) CreateMessage(input CreateMessageInput) (*models.Messag
}
if s.hub != nil {
// 1. 先广播到该对话的所有客户端(访客和已连接该对话的客服)
// 1. 先广播到该对话房间内的客户端(访客 + 已按该 conversation_id 建连的客服)
s.hub.BroadcastMessage(message.ConversationID, "new_message", message)
// 2. 如果是访客发送的消息,且对话模式是人工客服,才广播到所有客服
// 这样即使客服没有连接到这个对话,也能收到新消息的通知
// 注意:AI 模式下的访客消息不广播给客服(避免干扰)
if !input.SenderIsAgent && conv.ChatMode == "human" {
// 2. 人工会话(非内部测试):再向所有在线客服连接广播一次。
// - 原逻辑仅对「访客消息」广播,客服自己发的消息只进房间;若 WS 异常/多实例 Hub 不一致,客服台会迟迟看不到自己发的内容。
// - AI 模式不向全员推访客消息(保持原意);内部知识库会话不向其他客服(避免无关会话刷屏)。
// handleNewMessage 侧会按 conversation_id 去重,双播不会产生重复气泡。
if conv.ChatMode == "human" && conv.ConversationType != "internal" {
s.hub.BroadcastToAllAgents("new_message", message)
}
} else {
@@ -150,26 +150,6 @@ export default function AgentChatPage() {
loadMessages();
}, [conversationId, agent, loadConversationDetail, loadMessages]);
const handleSendMessage = useCallback(async () => {
if (!conversationId || !agent?.id || !messageInput.trim() || sending) {
return;
}
setSending(true);
try {
await sendMessageApi({
conversationId,
content: messageInput,
senderId: agent.id,
});
setMessageInput("");
} catch (error) {
console.error(error);
toast.error((error as Error).message);
} finally {
setSending(false);
}
}, [agent?.id, conversationId, messageInput, sending]);
const handleNewMessage = useCallback(
(message: MessageItem) => {
setMessages((prev) => {
@@ -243,6 +223,31 @@ export default function AgentChatPage() {
[conversationId, loadConversationDetail]
);
const handleSendMessage = useCallback(async () => {
if (!conversationId || !agent?.id || !messageInput.trim() || sending) {
return;
}
setSending(true);
try {
const created = await sendMessageApi({
conversationId,
content: messageInput,
senderId: agent.id,
});
setMessageInput("");
if (created) {
handleNewMessage(created);
} else {
await loadMessages();
}
} catch (error) {
console.error(error);
toast.error((error as Error).message);
} finally {
setSending(false);
}
}, [agent?.id, conversationId, messageInput, sending, handleNewMessage, loadMessages]);
const handleMessagesReadEvent = useCallback(
(payload: MessagesReadPayload) => {
if (!conversationId) {
@@ -5,9 +5,14 @@ import { ChevronDown } from "lucide-react";
export type ConversationFilter = "all" | "mine" | "others";
export type ConversationListStatus = "open" | "closed";
interface ConversationHeaderProps {
filter: ConversationFilter;
onFilterChange: (filter: ConversationFilter) => void;
/** 与「全部对话」同一行右侧:进行中 / 历史 */
listStatus?: ConversationListStatus;
onListStatusChange?: (status: ConversationListStatus) => void;
}
const FILTER_OPTIONS: { value: ConversationFilter; label: string }[] = [
@@ -19,6 +24,8 @@ const FILTER_OPTIONS: { value: ConversationFilter; label: string }[] = [
export function ConversationHeader({
filter,
onFilterChange,
listStatus,
onListStatusChange,
}: ConversationHeaderProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@@ -33,13 +40,16 @@ export function ConversationHeader({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
const showListStatus =
listStatus !== undefined && typeof onListStatusChange === "function";
return (
<div className="h-14 flex items-center px-3 border-b border-border bg-background flex-shrink-0">
<div className="relative" ref={ref}>
<div className="h-14 flex items-center gap-2 px-3 border-b border-border bg-background flex-shrink-0 min-w-0">
<div className="relative min-w-0 shrink" ref={ref}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border border-border bg-background hover:bg-muted/50 text-sm font-medium text-foreground transition-colors min-w-0"
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border border-border bg-background hover:bg-muted/50 text-sm font-medium text-foreground transition-colors max-w-[9.5rem] min-w-0"
>
<span className="truncate">{currentLabel}</span>
<ChevronDown
@@ -68,6 +78,34 @@ export function ConversationHeader({
</div>
)}
</div>
{showListStatus && (
<div className="ml-auto flex-shrink-0">
<div className="inline-flex rounded-md border border-border/70 bg-muted/30 p-0.5">
<button
type="button"
className={`px-2.5 py-1 rounded-[0.25rem] text-[11px] font-medium transition leading-none ${
listStatus === "open"
? "bg-green-600 text-white shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => onListStatusChange!("open")}
>
</button>
<button
type="button"
className={`px-2.5 py-1 rounded-[0.25rem] text-[11px] font-medium transition leading-none ${
listStatus === "closed"
? "bg-green-600 text-white shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => onListStatusChange!("closed")}
>
</button>
</div>
</div>
)}
</div>
);
}
@@ -50,33 +50,12 @@ export function ConversationSidebar({
)}
</div>
) : (
<div className="flex flex-col">
<ConversationHeader filter={filter} onFilterChange={onFilterChange} />
<div className="px-3 pb-2 flex items-center gap-2">
<button
type="button"
className={`px-3 py-1.5 rounded-md text-xs border transition ${
status === "open"
? "bg-green-600 text-white border-green-600"
: "bg-background text-muted-foreground border-border hover:text-foreground"
}`}
onClick={() => onStatusChange?.("open")}
>
</button>
<button
type="button"
className={`px-3 py-1.5 rounded-md text-xs border transition ${
status === "closed"
? "bg-green-600 text-white border-green-600"
: "bg-background text-muted-foreground border-border hover:text-foreground"
}`}
onClick={() => onStatusChange?.("closed")}
>
</button>
</div>
</div>
<ConversationHeader
filter={filter}
onFilterChange={onFilterChange}
listStatus={status}
onListStatusChange={onStatusChange}
/>
)}
<div className="flex-shrink-0 px-2 min-w-0">
<ConversationSearch value={searchQuery} onChange={onSearchChange} />
+59 -39
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
fetchConversationDetail,
@@ -195,44 +195,7 @@ export function useMessages({
refreshConversationDetail(conversationId);
}, [conversationId, agentId, effectiveIncludeAIMessages, loadMessages, refreshConversationDetail]);
const handleSendMessage = useCallback(
async (content: string, fileInfo?: { file_url: string; file_type: string; file_name: string; file_size: number; mime_type: string }) => {
if (!conversationId || !agentId || sending) {
return;
}
// 验证:必须有内容或文件
if (!content.trim() && !fileInfo) {
return;
}
setSending(true);
if (forceIncludeAIMessages) {
setAiThinking(true);
}
try {
await sendMessage({
conversationId,
content: content.trim(),
senderId: agentId,
fileUrl: fileInfo?.file_url,
fileType: fileInfo?.file_type as "image" | "document" | undefined,
fileName: fileInfo?.file_name,
fileSize: fileInfo?.file_size,
mimeType: fileInfo?.mime_type,
needWebSearch: forceIncludeAIMessages ? needWebSearch : undefined,
useWebSearch: forceIncludeAIMessages && needWebSearch ? true : undefined,
});
} catch (error) {
console.error(error);
if (forceIncludeAIMessages) {
setAiThinking(false);
}
throw error;
} finally {
setSending(false);
}
},
[agentId, conversationId, sending, forceIncludeAIMessages, needWebSearch]
);
const handleNewMessageRef = useRef<(message: MessageItem) => void>(() => {});
const handleNewMessage = useCallback(
(message: MessageItem) => {
@@ -339,6 +302,63 @@ export function useMessages({
[conversationId, updateConversation, refreshConversations, hasConversation, effectiveIncludeAIMessages, soundEnabled, forceIncludeAIMessages]
);
useEffect(() => {
handleNewMessageRef.current = handleNewMessage;
}, [handleNewMessage]);
const handleSendMessage = useCallback(
async (content: string, fileInfo?: { file_url: string; file_type: string; file_name: string; file_size: number; mime_type: string }) => {
if (!conversationId || !agentId || sending) {
return;
}
// 验证:必须有内容或文件
if (!content.trim() && !fileInfo) {
return;
}
setSending(true);
if (forceIncludeAIMessages) {
setAiThinking(true);
}
try {
const created = await sendMessage({
conversationId,
content: content.trim(),
senderId: agentId,
fileUrl: fileInfo?.file_url,
fileType: fileInfo?.file_type as "image" | "document" | undefined,
fileName: fileInfo?.file_name,
fileSize: fileInfo?.file_size,
mimeType: fileInfo?.mime_type,
needWebSearch: forceIncludeAIMessages ? needWebSearch : undefined,
useWebSearch: forceIncludeAIMessages && needWebSearch ? true : undefined,
});
// 发送成功即以接口返回为准合并到列表,避免生产环境 WS 丢事件时「发出去了但看不见」
if (created) {
handleNewMessageRef.current(created);
} else {
await loadMessages(conversationId, effectiveIncludeAIMessages);
}
} catch (error) {
console.error(error);
if (forceIncludeAIMessages) {
setAiThinking(false);
}
throw error;
} finally {
setSending(false);
}
},
[
agentId,
conversationId,
sending,
forceIncludeAIMessages,
needWebSearch,
loadMessages,
effectiveIncludeAIMessages,
]
);
const handleMessagesReadBroadcast = useCallback(
(payload: MessagesReadPayload, eventConversationId?: number) => {
const messageIds: number[] = Array.isArray(payload?.message_ids)
+52 -1
View File
@@ -2,6 +2,50 @@ import { apiUrl } from "@/lib/config";
import { MessageItem } from "../types";
import { reportFrontendLog } from "./systemLogApi";
/** 解析 POST /messages 返回的消息体(与 models.Message JSON 一致) */
function messageItemFromResponse(data: unknown): MessageItem | null {
if (!data || typeof data !== "object") {
return null;
}
const raw = data as Record<string, unknown>;
if (typeof raw.id !== "number" || typeof raw.conversation_id !== "number") {
return null;
}
return {
id: raw.id,
conversation_id: raw.conversation_id,
sender_id: typeof raw.sender_id === "number" ? raw.sender_id : 0,
sender_is_agent: Boolean(raw.sender_is_agent),
content: typeof raw.content === "string" ? raw.content : "",
created_at:
typeof raw.created_at === "string"
? raw.created_at
: new Date().toISOString(),
message_type:
typeof raw.message_type === "string" ? raw.message_type : undefined,
chat_mode: typeof raw.chat_mode === "string" ? raw.chat_mode : undefined,
is_read: Boolean(raw.is_read),
read_at:
raw.read_at === null || raw.read_at === undefined
? null
: String(raw.read_at),
file_url:
raw.file_url === null || raw.file_url === undefined
? null
: String(raw.file_url),
file_type:
typeof raw.file_type === "string" ? raw.file_type : undefined,
file_name:
typeof raw.file_name === "string" ? raw.file_name : undefined,
file_size:
typeof raw.file_size === "number" ? raw.file_size : undefined,
mime_type:
typeof raw.mime_type === "string" ? raw.mime_type : undefined,
sources_used:
typeof raw.sources_used === "string" ? raw.sources_used : undefined,
};
}
interface SendMessagePayload {
conversationId: number;
content: string;
@@ -106,7 +150,7 @@ export async function sendMessage({
useLLM,
useWebSearch,
needWebSearch,
}: SendMessagePayload): Promise<void> {
}: SendMessagePayload): Promise<MessageItem | null> {
const payload: Record<string, unknown> = {
conversation_id: conversationId,
content,
@@ -146,6 +190,13 @@ export async function sendMessage({
});
throw new Error(error.error || "发送消息失败");
}
try {
const data: unknown = await res.json();
return messageItemFromResponse(data);
} catch {
return null;
}
}
export interface MarkMessagesReadResult {