mirror of
https://github.com/2930134478/AI-CS.git
synced 2026-06-15 08:45:41 +08:00
修复会话丢失bug
This commit is contained in:
@@ -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 返回指定会话的消息列表。
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user