"use client" import { create } from "zustand" import { fetchAgentConversations, fetchAgentMessages, markAgentMessageRead, recallAgentMessage, sendAgentMessage, uploadAgentConversationAttachment, uploadAgentConversationImage, type AgentAsset, type AgentConversation, type AgentMessage, } from "@/lib/api/agent" import { summarizeIMMessage } from "@/lib/im-message" import { generateUUID } from "@/lib/utils" export const agentConversationFilterOptions = [ // { value: "mine", label: "我的" }, { value: "active", label: "处理中" }, { value: "pending", label: "待接入" }, { value: "closed", label: "已关闭" }, ] as const export type AgentConversationFilterKey = (typeof agentConversationFilterOptions)[number]["value"] function buildConversationQuery(filter: AgentConversationFilterKey, keyword: string) { const query: Record = { filter, keyword: keyword.trim() || undefined, limit: 100, } return query } type LoadMessagesOptions = { forceLoading?: boolean reset?: boolean } function ensureArray(value: T[] | null | undefined): T[] { return Array.isArray(value) ? value : [] } function mergeMessagesByIdAsc( a: AgentMessage[], b: AgentMessage[] ): AgentMessage[] { const byId = new Map() for (const m of a) { byId.set(m.id, m) } for (const m of b) { byId.set(m.id, m) } return Array.from(byId.values()).sort((x, y) => x.id - y.id) } function parseCursorId(cursor: string): number { const n = Number.parseInt(cursor, 10) return Number.isFinite(n) && n > 0 ? n : 0 } /** 下一页「更旧」请求应传入的游标:当前已加载列表中的最小 message id(后端用 id < cursor) */ function cursorFromLoadedMessages(messages: AgentMessage[]): string { if (messages.length === 0) { return "" } return String(Math.min(...messages.map((m) => m.id))) } function minMessageId(messages: AgentMessage[]): number | null { if (messages.length === 0) { return null } return Math.min(...messages.map((m) => m.id)) } /** * 拉「最新一页」做增量合并后,是否仍显示「还有更旧」。 * 若本地已确认没有更旧,且合并后最早一条 id 没有变小,则不能用接口对「最新一页」的 hasMore 再次打开(满页会误报)。 */ function hasMoreAfterLatestSyncMerge(args: { previousMessages: AgentMessage[] previousHasMore: boolean merged: AgentMessage[] apiHasMore: boolean }): boolean { const prevMin = minMessageId(args.previousMessages) const mergedMin = minMessageId(args.merged) if (mergedMin === null) { return Boolean(args.apiHasMore) } if ( !args.previousHasMore && prevMin !== null && mergedMin >= prevMin ) { return false } return args.previousHasMore || Boolean(args.apiHasMore) } type AgentConversationsStore = { searchKeyword: string conversationFilter: AgentConversationFilterKey conversations: AgentConversation[] conversationsLoading: boolean conversationsLoaded: boolean selectedConversationId: number | null messages: AgentMessage[] messagesLoading: boolean messagesLoadingMore: boolean messagesCursor: string messagesHasMore: boolean messagesLoadedConversationId: number | null sending: boolean uploadingAsset: boolean recallingMessageId: number readingMessageId: number setSearchKeyword: (keyword: string) => void setConversationFilter: (filter: AgentConversationFilterKey) => void setConversationTags: ( conversationId: number, tags: AgentConversation["tags"] ) => void loadConversations: () => Promise selectConversation: (conversationId: number) => Promise loadMessages: (conversationId: number, options?: LoadMessagesOptions) => Promise loadOlderMessages: () => Promise syncLatestMessages: (conversationId: number) => Promise markSelectedConversationRead: () => Promise sendMessage: (html: string) => Promise uploadImage: (file: File) => Promise sendAttachment: (file: File) => Promise recallMessage: (messageId: number) => Promise } let conversationsRequestSeq = 0 let messagesRequestSeq = 0 export const useAgentConversationsStore = create((set, get) => ({ searchKeyword: "", conversationFilter: "active", conversations: [], conversationsLoading: false, conversationsLoaded: false, selectedConversationId: null, messages: [], messagesLoading: false, messagesLoadingMore: false, messagesCursor: "", messagesHasMore: false, messagesLoadedConversationId: null, sending: false, uploadingAsset: false, recallingMessageId: 0, readingMessageId: 0, setSearchKeyword: (keyword) => { set({ searchKeyword: keyword }) }, setConversationFilter: (filter) => { set({ conversationFilter: filter }) }, setConversationTags: (conversationId, tags) => { set((state) => ({ conversations: state.conversations.map((item) => item.id === conversationId ? { ...item, tags: tags && tags.length > 0 ? tags : [], } : item ), })) }, loadConversations: async () => { const requestSeq = ++conversationsRequestSeq const store = get() if (!store.conversationsLoaded) { set({ conversationsLoading: true }) } try { const data = await fetchAgentConversations( buildConversationQuery(store.conversationFilter, store.searchKeyword) ) const conversations = ensureArray(data.results) if (requestSeq !== conversationsRequestSeq) { return } const currentSelectedId = get().selectedConversationId const hasCurrentSelection = currentSelectedId !== null && conversations.some((item) => item.id === currentSelectedId) const nextSelectedId = hasCurrentSelection ? currentSelectedId : (conversations[0]?.id ?? null) const selectionChanged = nextSelectedId !== currentSelectedId set({ conversations, conversationsLoaded: true, conversationsLoading: false, selectedConversationId: nextSelectedId, }) if (nextSelectedId === null) { set({ messages: [], messagesLoading: false, messagesLoadingMore: false, messagesCursor: "", messagesHasMore: false, messagesLoadedConversationId: null, }) return } if (selectionChanged || get().messagesLoadedConversationId === null) { await get().loadMessages(nextSelectedId, { forceLoading: true, reset: true, }) } } catch (error) { if (requestSeq === conversationsRequestSeq) { set({ conversationsLoading: false }) } throw error } }, selectConversation: async (conversationId) => { if (get().selectedConversationId === conversationId) { return } set({ selectedConversationId: conversationId, messages: [], messagesLoading: true, messagesLoadingMore: false, messagesCursor: "", messagesHasMore: false, messagesLoadedConversationId: null, }) await get().loadMessages(conversationId, { forceLoading: true, reset: true, }) }, loadMessages: async (conversationId, options = {}) => { const requestSeq = ++messagesRequestSeq const store = get() const shouldShowLoading = options.forceLoading || store.messagesLoadedConversationId !== conversationId if (shouldShowLoading) { set({ messagesLoading: true, ...(options.reset ? { messages: [], messagesCursor: "", messagesHasMore: false, } : {}), }) } try { const data = await fetchAgentMessages({ conversationId, limit: 50, }) if (requestSeq !== messagesRequestSeq) { return } if (get().selectedConversationId !== conversationId) { return } const list = ensureArray(data.results) set({ messages: list, messagesLoading: false, messagesLoadedConversationId: conversationId, messagesCursor: cursorFromLoadedMessages(list) || (data.cursor ?? ""), messagesHasMore: Boolean(data.hasMore), }) } catch (error) { if (requestSeq === messagesRequestSeq) { set({ messagesLoading: false }) } throw error } }, loadOlderMessages: async () => { const conversationId = get().selectedConversationId if (!conversationId || get().messagesLoadingMore || !get().messagesHasMore) { return } const cursorId = parseCursorId(get().messagesCursor) if (cursorId <= 0) { return } set({ messagesLoadingMore: true }) try { const data = await fetchAgentMessages({ conversationId, cursor: cursorId, limit: 50, }) if (get().selectedConversationId !== conversationId) { return } const incoming = ensureArray(data.results) set((state) => { const merged = mergeMessagesByIdAsc(incoming, state.messages) return { messages: merged, messagesCursor: cursorFromLoadedMessages(merged) || (data.cursor ?? state.messagesCursor), messagesHasMore: Boolean(data.hasMore), messagesLoadingMore: false, } }) } catch (error) { set({ messagesLoadingMore: false }) throw error } }, syncLatestMessages: async (conversationId) => { if (conversationId <= 0) { return } try { const data = await fetchAgentMessages({ conversationId, limit: 50, }) if (get().selectedConversationId !== conversationId) { return } const batch = ensureArray(data.results) if (batch.length === 0) { return } const firstId = batch[0]!.id set((state) => { const preserved = state.messages.filter((m) => m.id < firstId) const merged = mergeMessagesByIdAsc(preserved, batch) return { messages: merged, messagesCursor: cursorFromLoadedMessages(merged) || (data.cursor ?? state.messagesCursor), messagesHasMore: hasMoreAfterLatestSyncMerge({ previousMessages: state.messages, previousHasMore: state.messagesHasMore, merged, apiHasMore: Boolean(data.hasMore), }), } }) } catch { // 实时同步失败不抛给 WS 回调 } }, markSelectedConversationRead: async () => { const store = get() const conversationId = store.selectedConversationId const conversation = store.conversations.find((item) => item.id === conversationId) const lastMessage = store.messages.at(-1) if (!conversationId || !conversation || !lastMessage) { return } if ( conversation.agentUnreadCount <= 0 && (conversation.agentLastReadMessageId ?? 0) >= lastMessage.id ) { return } if (store.readingMessageId === lastMessage.id) { return } set({ readingMessageId: lastMessage.id }) try { await markAgentMessageRead(conversationId, lastMessage.id) set((current) => { if (current.selectedConversationId !== conversationId) { return { readingMessageId: 0 } } return { readingMessageId: 0, messages: current.messages.map((item) => item.seqNo <= lastMessage.seqNo ? { ...item, agentRead: true, } : item ), conversations: current.conversations.map((item) => item.id === conversationId ? { ...item, agentUnreadCount: 0, agentLastReadMessageId: lastMessage.id, agentLastReadSeqNo: lastMessage.seqNo, } : item ), } }) } catch (error) { set({ readingMessageId: 0 }) throw error } }, sendMessage: async (html) => { const trimmedContent = html.trim() const { selectedConversationId, sending } = get() if (!selectedConversationId || !trimmedContent || sending) { return null } set({ sending: true }) try { const message = await sendAgentMessage({ conversationId: selectedConversationId, messageType: "html", content: trimmedContent, clientMsgId: `agent_${generateUUID()}`, }) if (get().selectedConversationId === selectedConversationId) { set((current) => ({ messages: current.messages.some((m) => m.id === message.id) ? current.messages.map((m) => (m.id === message.id ? message : m)) : [...current.messages, message], conversations: current.conversations.map((item) => item.id === selectedConversationId ? { ...item, lastMessageAt: message.sentAt, lastActiveAt: message.sentAt, lastMessageSummary: summarizeIMMessage({ messageType: "html", content: trimmedContent, }), agentUnreadCount: 0, customerUnreadCount: (item.customerUnreadCount ?? 0) + 1, agentLastReadMessageId: message.id, agentLastReadSeqNo: message.seqNo, } : item ), })) } return message } finally { set({ sending: false }) } }, uploadImage: async (file) => { const { selectedConversationId, sending, uploadingAsset } = get() if (!selectedConversationId || sending || uploadingAsset) { return null } set({ uploadingAsset: true }) try { return await uploadAgentConversationImage(selectedConversationId, file) } finally { set({ uploadingAsset: false }) } }, sendAttachment: async (file) => { const { selectedConversationId, sending, uploadingAsset } = get() if (!selectedConversationId || sending || uploadingAsset) { return null } set({ uploadingAsset: true }) try { const asset = await uploadAgentConversationAttachment(selectedConversationId, file) const message = await sendAgentMessage({ conversationId: selectedConversationId, messageType: "attachment", content: asset.filename, payload: JSON.stringify({ assetId: asset.assetId }), clientMsgId: `agent_attachment_${generateUUID()}`, }) if (get().selectedConversationId === selectedConversationId) { set((current) => ({ messages: current.messages.some((m) => m.id === message.id) ? current.messages.map((m) => (m.id === message.id ? message : m)) : [...current.messages, message], conversations: current.conversations.map((item) => item.id === selectedConversationId ? { ...item, lastMessageAt: message.sentAt, lastActiveAt: message.sentAt, lastMessageSummary: summarizeIMMessage(message), agentUnreadCount: 0, customerUnreadCount: (item.customerUnreadCount ?? 0) + 1, agentLastReadMessageId: message.id, agentLastReadSeqNo: message.seqNo, } : item ), })) } return message } finally { set({ uploadingAsset: false }) } }, recallMessage: async (messageId) => { const { selectedConversationId, recallingMessageId } = get() if (!selectedConversationId || messageId <= 0 || recallingMessageId === messageId) { return null } set({ recallingMessageId: messageId }) try { const message = await recallAgentMessage(messageId) if (get().selectedConversationId === selectedConversationId) { set((current) => { const nextMessages = current.messages.map((item) => item.id === message.id ? message : item ) const lastActiveMessage = [...nextMessages] .reverse() .find((item) => !item.recalledAt && item.sendStatus !== 6) return { recallingMessageId: 0, messages: nextMessages, conversations: current.conversations.map((item) => item.id === selectedConversationId ? { ...item, lastMessageId: lastActiveMessage?.id ?? 0, lastMessageAt: lastActiveMessage?.sentAt ?? "", lastMessageSummary: lastActiveMessage ? summarizeIMMessage(lastActiveMessage) : "", } : item ), } }) } else { set({ recallingMessageId: 0 }) } return message } catch (error) { set({ recallingMessageId: 0 }) throw error } }, })) export const agentConversationSelectors = { selectedConversation: (state: AgentConversationsStore) => state.conversations.find((item) => item.id === state.selectedConversationId) ?? null, }