mirror of
https://github.com/2930134478/AI-CS.git
synced 2026-06-15 00:44:30 +08:00
267 lines
8.3 KiB
TypeScript
267 lines
8.3 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
|
||
import {
|
||
fetchConversations,
|
||
searchConversations,
|
||
} from "../../agent/services/conversationApi";
|
||
import type { ConversationListType } from "../../agent/services/conversationApi";
|
||
import { ConversationSummary, VisitorStatusUpdatePayload } from "../../agent/types";
|
||
import { useWebSocket } from "./useWebSocket";
|
||
import { WSMessage } from "@/lib/websocket";
|
||
import { ChatWebSocketPayload } from "../../agent/types";
|
||
|
||
const sortByUpdatedAtDesc = (list: ConversationSummary[]) =>
|
||
[...list].sort(
|
||
(a, b) =>
|
||
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
|
||
);
|
||
|
||
import type { ConversationFilter } from "@/components/dashboard/ConversationHeader";
|
||
|
||
interface UseConversationsOptions {
|
||
agentId?: number | null;
|
||
filter?: ConversationFilter;
|
||
/** 内部对话(知识库测试)时传 "internal",默认访客对话 "visitor" */
|
||
listType?: ConversationListType;
|
||
}
|
||
|
||
export function useConversations(options?: UseConversationsOptions) {
|
||
const { agentId, filter = "all", listType = "visitor" } = options || {};
|
||
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||
const [filteredConversations, setFilteredConversations] = useState<
|
||
ConversationSummary[]
|
||
>([]);
|
||
const [selectedConversationId, setSelectedConversationId] = useState<
|
||
number | null
|
||
>(null);
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
const [loading, setLoading] = useState(true);
|
||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||
|
||
const searchRef = useRef("");
|
||
|
||
// 根据 filter 过滤会话
|
||
const applyFilter = useCallback(
|
||
(conversations: ConversationSummary[]): ConversationSummary[] => {
|
||
if (!agentId) {
|
||
return conversations;
|
||
}
|
||
|
||
switch (filter) {
|
||
case "mine":
|
||
// 只显示当前用户参与过的会话(has_participated === true)
|
||
// 即当前用户在该会话中发送过消息的会话
|
||
return conversations.filter((conv) => conv.has_participated === true);
|
||
case "others":
|
||
// 显示除了当前用户参与过的其他人的会话(has_participated !== true)
|
||
return conversations.filter((conv) => conv.has_participated !== true);
|
||
case "all":
|
||
default:
|
||
return conversations;
|
||
}
|
||
},
|
||
[agentId, filter]
|
||
);
|
||
|
||
const loadConversations = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const data = await fetchConversations(agentId ?? undefined, listType === "internal" ? { type: "internal" } : undefined);
|
||
setConversations(data);
|
||
const filtered = listType === "internal" ? data : applyFilter(data);
|
||
if (!searchRef.current.trim()) {
|
||
setFilteredConversations(filtered);
|
||
}
|
||
setSelectedConversationId((prev) => {
|
||
if (prev) {
|
||
return prev;
|
||
}
|
||
return filtered.length > 0 ? filtered[0].id : null;
|
||
});
|
||
} catch (error) {
|
||
console.error(error);
|
||
} finally {
|
||
setLoading(false);
|
||
setIsInitialLoad(false);
|
||
}
|
||
}, [applyFilter, agentId, filter, listType]);
|
||
|
||
useEffect(() => {
|
||
loadConversations();
|
||
}, [loadConversations]);
|
||
|
||
// 当 filter / listType 改变时,重新应用过滤(不重新加载数据)
|
||
useEffect(() => {
|
||
if (isInitialLoad) {
|
||
return;
|
||
}
|
||
const filtered = listType === "internal" ? conversations : applyFilter(conversations);
|
||
setFilteredConversations(sortByUpdatedAtDesc(filtered));
|
||
}, [filter, listType, conversations, isInitialLoad, applyFilter]);
|
||
|
||
useEffect(() => {
|
||
if (isInitialLoad) {
|
||
return;
|
||
}
|
||
const handler = setTimeout(async () => {
|
||
const query = searchQuery.trim();
|
||
searchRef.current = query;
|
||
if (!query) {
|
||
const filtered = listType === "internal" ? conversations : applyFilter(conversations);
|
||
setFilteredConversations(sortByUpdatedAtDesc(filtered));
|
||
return;
|
||
}
|
||
if (listType === "internal") {
|
||
setFilteredConversations(sortByUpdatedAtDesc(conversations.filter((c) => (c.last_message?.content ?? "").toLowerCase().includes(query.toLowerCase()))));
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
try {
|
||
setLoading(true);
|
||
const data = await searchConversations(query, agentId ?? undefined);
|
||
const filtered = applyFilter(data);
|
||
setFilteredConversations(sortByUpdatedAtDesc(filtered));
|
||
} catch (error) {
|
||
console.error(error);
|
||
setFilteredConversations([]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, 300);
|
||
|
||
return () => clearTimeout(handler);
|
||
}, [searchQuery, conversations, isInitialLoad, applyFilter, agentId, listType]);
|
||
|
||
const selectConversation = useCallback((conversationId: number | null) => {
|
||
setSelectedConversationId((prev) =>
|
||
prev === conversationId ? prev : conversationId
|
||
);
|
||
}, []);
|
||
|
||
const updateConversation = useCallback(
|
||
(
|
||
conversationId: number,
|
||
updater: (conversation: ConversationSummary) => ConversationSummary,
|
||
options?: { skipResort?: boolean }
|
||
) => {
|
||
const applyUpdate = (list: ConversationSummary[]) => {
|
||
let changed = false;
|
||
const next = list.map((conv) => {
|
||
if (conv.id === conversationId) {
|
||
changed = true;
|
||
return updater(conv);
|
||
}
|
||
return conv;
|
||
});
|
||
if (!changed) {
|
||
return list;
|
||
}
|
||
if (options?.skipResort) {
|
||
return next;
|
||
}
|
||
return sortByUpdatedAtDesc(next);
|
||
};
|
||
|
||
setConversations((prev) => applyUpdate(prev));
|
||
setFilteredConversations((prev) => {
|
||
if (searchRef.current && !prev.some((item) => item.id === conversationId)) {
|
||
return prev;
|
||
}
|
||
return applyUpdate(prev);
|
||
});
|
||
},
|
||
[]
|
||
);
|
||
|
||
const setAllConversations = useCallback((data: ConversationSummary[]) => {
|
||
setConversations(data);
|
||
if (!searchRef.current.trim()) {
|
||
const filtered = applyFilter(data);
|
||
setFilteredConversations(filtered);
|
||
}
|
||
}, [applyFilter]);
|
||
|
||
const hasConversation = useCallback(
|
||
(conversationId: number) => {
|
||
return conversations.some((conv) => conv.id === conversationId);
|
||
},
|
||
[conversations]
|
||
);
|
||
|
||
// 建立全局 WebSocket 连接以接收 visitor_status_update 等全局事件
|
||
// 使用第一个对话的 ID(如果存在),否则不建立连接
|
||
const globalConversationId = conversations.length > 0 ? conversations[0].id : null;
|
||
|
||
// 处理 visitor_status_update 事件
|
||
const handleVisitorStatusUpdate = useCallback(
|
||
(event: WSMessage<ChatWebSocketPayload>) => {
|
||
if (event.type === "visitor_status_update" && event.data) {
|
||
const payload = event.data as VisitorStatusUpdatePayload;
|
||
if (payload?.conversation_id) {
|
||
if (payload.is_online === true) {
|
||
// 在线:更新为当前时间(实时更新在线状态)
|
||
updateConversation(payload.conversation_id, (conv) => ({
|
||
...conv,
|
||
last_seen_at: new Date().toISOString(),
|
||
}));
|
||
}
|
||
// 离线时,last_seen_at 会在后端更新,这里不需要特殊处理
|
||
// 因为对话列表会定期刷新,或者通过其他方式更新
|
||
}
|
||
}
|
||
},
|
||
[updateConversation]
|
||
);
|
||
|
||
// 建立全局 WebSocket 连接(用于接收全局事件)
|
||
useWebSocket<ChatWebSocketPayload>({
|
||
conversationId: globalConversationId,
|
||
enabled: Boolean(globalConversationId && agentId),
|
||
isVisitor: false,
|
||
agentId: agentId ?? undefined,
|
||
onMessage: handleVisitorStatusUpdate,
|
||
onError: (error) => {
|
||
// 静默处理错误,避免影响用户体验
|
||
},
|
||
onClose: () => {
|
||
// 静默处理关闭,避免影响用户体验
|
||
},
|
||
});
|
||
|
||
const contextValue = useMemo(
|
||
() => ({
|
||
conversations,
|
||
filteredConversations,
|
||
selectedConversationId,
|
||
searchQuery,
|
||
loading,
|
||
isInitialLoad,
|
||
setSearchQuery,
|
||
selectConversation,
|
||
refresh: loadConversations,
|
||
updateConversation,
|
||
setAllConversations,
|
||
hasConversation,
|
||
}),
|
||
[
|
||
conversations,
|
||
filteredConversations,
|
||
selectedConversationId,
|
||
searchQuery,
|
||
loading,
|
||
isInitialLoad,
|
||
selectConversation,
|
||
loadConversations,
|
||
updateConversation,
|
||
setAllConversations,
|
||
setSearchQuery,
|
||
hasConversation,
|
||
]
|
||
);
|
||
|
||
return contextValue;
|
||
}
|
||
|