Sfoglia il codice sorgente

修复会话丢失bug

537yaha 3 mesi fa
parent
commit
25c7e54648

+ 0 - 0
_tmp_csdn_article.html


+ 3 - 2
backend/controller/message_controller.go

@@ -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
backend/service/message_service.go

@@ -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 {

+ 25 - 20
frontend/app/agent/chat/[conversationId]/page.tsx

@@ -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) {

+ 41 - 3
frontend/components/dashboard/ConversationHeader.tsx

@@ -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>
   );
 }

+ 6 - 27
frontend/components/dashboard/ConversationSidebar.tsx

@@ -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
frontend/features/agent/hooks/useMessages.ts

@@ -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
frontend/features/agent/services/messageApi.ts

@@ -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 {