feat(chat): Socket.IO 双端通道、消息持久化与幂等

访客与客服分别鉴权接入,消息带客户端 ID 去重,重发只产生一条业务记录。
This commit is contained in:
WangShen007 committed 2026-09-26 22:20:00 +08:00
1 parent 5d0a48505a
commit db48cac9e0
23 files changed
+7057

No files matched your search

+47
View File
@@ -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
}
}
+112
View File
@@ -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 }
}
+52
View File
@@ -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))
}
+70
View File
@@ -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 }
}