Files
AI-CS/frontend/features/agent/hooks/useConversations.ts
T
2026-03-25 18:50:58 +08:00

274 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 {
// 内部对话(知识库测试)必须带 user_id,后端否则返回 400;未登录或 agentId 未就绪时不请求
if (listType === "internal" && !agentId) {
setConversations([]);
setFilteredConversations([]);
setSelectedConversationId(null);
return;
}
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;
}