feat(chat): Socket.IO 双端通道、消息持久化与幂等
访客与客服分别鉴权接入,消息带客户端 ID 去重,重发只产生一条业务记录。
This commit is contained in:
1 parent
5d0a48505a
commit
db48cac9e0
23 files changed
+7057
No files matched your search
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { toast } from 'vue-sonner'
|
||||
import { chatService } from '@/services/chat'
|
||||
import type { ChatDetail } from '@/types/chat'
|
||||
|
||||
const TOAST_OPTIONS = { duration: 4000, closeButton: true }
|
||||
|
||||
/**
|
||||
* Take the AI out of a chat and queue it for the team, without claiming it.
|
||||
*
|
||||
* Shared by the chat pane and the info panel — both offer the action, and a
|
||||
* copy each is how the takeover button ended up behaving differently in the
|
||||
* two places. Returns the updated chat so the caller can refresh its own
|
||||
* state, or null when the call failed and nothing changed.
|
||||
*/
|
||||
export async function routeChatToHuman(sessionId: string): Promise<ChatDetail | null> {
|
||||
try {
|
||||
const updated = await chatService.routeToHuman(sessionId)
|
||||
toast.success('会话已转交给你的团队', {
|
||||
description: 'AI 已停止回复,现在任何人都可以接管。',
|
||||
...TOAST_OPTIONS,
|
||||
})
|
||||
return updated
|
||||
} catch (err: any) {
|
||||
console.error('Failed to route chat to a human:', err)
|
||||
toast.error('转交会话失败', {
|
||||
description: err.response?.data?.detail || '请重试',
|
||||
...TOAST_OPTIONS,
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
interface TakeoverCandidate {
|
||||
status?: string | null
|
||||
user_id?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a chat is claimable: still open, and nobody holds it.
|
||||
*
|
||||
* Covers both an AI-handled conversation (status open, no user) and one the AI
|
||||
* has queued for a human (status transferred). Single source of truth — the
|
||||
* chat pane and the info panel each carried their own version, and the pane's
|
||||
* narrower one meant the only way to claim an AI chat was via the info panel.
|
||||
*
|
||||
* Mirrors the backend guard: takeover_session refuses once user_id is set, so
|
||||
* offering the action for a chat another agent holds would only ever fail.
|
||||
*/
|
||||
export function canTakeOverChat(chat?: TakeoverCandidate | null): boolean {
|
||||
if (!chat) return false
|
||||
return chat.status !== 'closed' && !chat.user_id
|
||||
}
|
||||
|
||||
/** Who is currently answering the customer. */
|
||||
export type ChatHandlerKind = 'ai' | 'waiting' | 'human' | 'closed'
|
||||
|
||||
export interface ChatHandler {
|
||||
kind: ChatHandlerKind
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface HandlerCandidate extends TakeoverCandidate {
|
||||
user_name?: string | null
|
||||
group_id?: string | null
|
||||
agent?: { ai_replies_enabled?: boolean } | null
|
||||
}
|
||||
|
||||
/** Every label the handler can carry, in one place — no per-surface strings. */
|
||||
const HANDLER_LABELS = {
|
||||
ai: 'AI',
|
||||
waiting: '等待人工客服',
|
||||
closed: '已结束',
|
||||
you: '你',
|
||||
someone: '其他客服',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* The human holding the chat, named for display, or null while the AI has it.
|
||||
*
|
||||
* Independent of open/closed: a finished conversation still had somebody on it,
|
||||
* which is what the info panel's "Assigned to" reports.
|
||||
*/
|
||||
export function chatAssignee(
|
||||
chat?: HandlerCandidate | null,
|
||||
currentUserId?: string | null
|
||||
): string | null {
|
||||
if (!chat?.user_id) return null
|
||||
if (currentUserId && chat.user_id === currentUserId) return HANDLER_LABELS.you
|
||||
return chat.user_name || HANDLER_LABELS.someone
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive who is handling a chat, for both the inbox list and the chat pane.
|
||||
*
|
||||
* Mirrors the server-side guards: `user_id` set means a human owns the chat and
|
||||
* the AI never runs; `transferred` with no assignee means it is queued for a
|
||||
* human; an agent with AI switched off is waiting for one from the first
|
||||
* message; anything else open is still the AI's. Keeping that derivation here
|
||||
* means the list badge, the chat header and the info panel cannot drift apart
|
||||
* the way their three hand-rolled copies did.
|
||||
*
|
||||
* A missing chat reads as AI-handled, which is what an unclaimed session is.
|
||||
*/
|
||||
export function chatHandler(
|
||||
chat?: HandlerCandidate | null,
|
||||
currentUserId?: string | null
|
||||
): ChatHandler {
|
||||
if (chat?.status === 'closed') {
|
||||
return { kind: 'closed', label: HANDLER_LABELS.closed }
|
||||
}
|
||||
|
||||
const assignee = chatAssignee(chat, currentUserId)
|
||||
if (assignee) return { kind: 'human', label: assignee }
|
||||
|
||||
// Queued for people: the AI handed it over, it sits in a group's queue, or
|
||||
// the agent never answers with AI at all. Nobody has claimed it yet, so no
|
||||
// reply is coming until someone does.
|
||||
if (
|
||||
chat &&
|
||||
(chat.status === 'transferred' ||
|
||||
chat.group_id ||
|
||||
chat.agent?.ai_replies_enabled === false)
|
||||
) {
|
||||
return { kind: 'waiting', label: HANDLER_LABELS.waiting }
|
||||
}
|
||||
|
||||
return { kind: 'ai', label: HANDLER_LABELS.ai }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Message } from '@/types/chat'
|
||||
|
||||
/**
|
||||
* An id for a message the inbox is about to send, so the backend's events
|
||||
* about it can be matched exactly. crypto.randomUUID only exists in secure
|
||||
* contexts — a self-hosted dashboard served over plain http on a LAN address
|
||||
* has none — so fall back to a time-plus-random id, unique enough for one tab.
|
||||
*/
|
||||
export function newClientId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
/** The ways a live event can name a message in the open conversation. */
|
||||
export interface MessageRef {
|
||||
/** The stored chat_history row id */
|
||||
messageId?: number | null
|
||||
/** The id the inbox gave a message it sent, before the row id is known */
|
||||
clientId?: string | null
|
||||
/** The channel's id for the message (WhatsApp wamid) */
|
||||
externalId?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The message an event (delivery receipt, delivery error, stored ack) is
|
||||
* about — matched exactly, never guessed: a receipt that names no message on
|
||||
* screen is ignored and shows after the next refetch instead.
|
||||
*/
|
||||
export function findMessage(messages: Message[], ref: MessageRef): Message | undefined {
|
||||
return messages.find(m =>
|
||||
(ref.messageId != null && m.id === ref.messageId) ||
|
||||
(!!ref.clientId && m.client_id === ref.clientId) ||
|
||||
(!!ref.externalId && m.external_message_id === ref.externalId))
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The line under the agent's name in the widget header.
|
||||
*
|
||||
* It used to be the hardcoded "Online · replies instantly" — asserted at 3am
|
||||
* with everyone logged off, on an agent whose AI never answers. What the widget
|
||||
* may honestly claim depends on who has to reply:
|
||||
*
|
||||
* - the AI answers → it really is instant, around the clock
|
||||
* - a person has to answer → only true inside the organization's business hours
|
||||
*
|
||||
* Availability is decided server-side from business hours alone. `is_online` is
|
||||
* set at login and cleared only on logout or a manual toggle, so it reports
|
||||
* people who closed a tab days ago as present.
|
||||
*/
|
||||
export type PresenceMode = 'ai' | 'human'
|
||||
|
||||
export interface WidgetPresence {
|
||||
mode?: PresenceMode
|
||||
available?: boolean
|
||||
}
|
||||
|
||||
export interface PresenceLine {
|
||||
text: string
|
||||
online: boolean
|
||||
}
|
||||
|
||||
const PRESENCE_TEXT = {
|
||||
ai: '在线 · 立即回复',
|
||||
human: '在线 · 通常几分钟内回复',
|
||||
away: "离开 · 回来后尽快回复",
|
||||
} as const
|
||||
|
||||
/**
|
||||
* @param presence what the server said when the widget loaded
|
||||
* @param handedOverToHuman a person has taken this conversation over, so the
|
||||
* AI is no longer answering it whatever the agent is configured to do
|
||||
*/
|
||||
export function presenceLine(
|
||||
presence?: WidgetPresence | null,
|
||||
handedOverToHuman = false,
|
||||
): PresenceLine {
|
||||
// Absent presence means an older widget payload: keep the previous wording
|
||||
// rather than declaring the team away on no evidence.
|
||||
const mode: PresenceMode = handedOverToHuman ? 'human' : (presence?.mode ?? 'ai')
|
||||
|
||||
if (mode === 'ai') {
|
||||
return { text: PRESENCE_TEXT.ai, online: true }
|
||||
}
|
||||
|
||||
const available = presence?.available !== false
|
||||
return available
|
||||
? { text: PRESENCE_TEXT.human, online: true }
|
||||
: { text: PRESENCE_TEXT.away, online: false }
|
||||
}
|
||||
Reference in new issue
Block a user