Files
ai-agent/web/lib/stores/agent-conversations.ts
T

601 lines
17 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 { 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: "ai_serving", label: "AI接待中" },
{ 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<string, string | number | undefined> = {
filter,
keyword: keyword.trim() || undefined,
limit: 100,
}
return query
}
type LoadMessagesOptions = {
forceLoading?: boolean
reset?: boolean
}
function ensureArray<T>(value: T[] | null | undefined): T[] {
return Array.isArray(value) ? value : []
}
function mergeMessagesByIdAsc(
a: AgentMessage[],
b: AgentMessage[]
): AgentMessage[] {
const byId = new Map<number, AgentMessage>()
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<void>
selectConversation: (conversationId: number) => Promise<void>
loadMessages: (conversationId: number, options?: LoadMessagesOptions) => Promise<void>
loadOlderMessages: () => Promise<void>
syncLatestMessages: (conversationId: number) => Promise<void>
markSelectedConversationRead: () => Promise<void>
sendMessage: (html: string) => Promise<AgentMessage | null>
uploadImage: (file: File) => Promise<AgentAsset | null>
sendAttachment: (file: File) => Promise<AgentMessage | null>
recallMessage: (messageId: number) => Promise<AgentMessage | null>
}
let conversationsRequestSeq = 0
let messagesRequestSeq = 0
export const useAgentConversationsStore = create<AgentConversationsStore>((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,
}