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

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

No files matched your search

+811
View File
@@ -0,0 +1,811 @@
/*
Copyright 2024-2026 ReplyPier
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 { ref } from 'vue'
import { io, Socket } from 'socket.io-client'
import type { HumanAgent, SocketError } from '../types/widget'
import { getErrorMessage } from '../types/widget'
import { widgetEnv } from '../webclient/widget-env'
import { readTokenClaims } from './useConversationToken'
import type { Message } from '@/types/chat'
type ConnectionStatus = 'connected' | 'connecting' | 'failed'
/** The conversation this connection landed in. */
export interface SessionState {
session_id: string
/** True when the conversation belongs to the visitor the host page identified. */
authenticated: boolean
/** True when the server created it, false when it resumed an open one. */
created: boolean
}
// How long to wait for the server to confirm a chat was closed. Generous enough for
// a slow round trip, short enough that the visitor is not left staring at a button.
const END_CHAT_TIMEOUT_MS = 5000
export function useWidgetSocket() {
const messages = ref<Message[]>([])
const loading = ref(false)
const errorMessage = ref('')
const showError = ref(false)
const loadingHistory = ref(false)
const hasStartedChat = ref(false)
const connectionStatus = ref<ConnectionStatus>('connecting')
const retryCount = ref(0)
const MAX_RETRIES = 5
const humanAgent = ref<HumanAgent>({})
const currentForm = ref<any>(null)
const currentSessionId = ref<string>('')
// The typing indicator is driven solely by the server's `bot_typing`
// event. The widget never guesses: a reply it optimistically waited for is
// exactly how the dots ended up spinning forever whenever nothing was
// going to answer (AI switched off, a human handling it, a queued chat).
let botTypingTimeout: ReturnType<typeof setTimeout> | null = null
/** Last resort: a backend that dies mid-reply must not strand the dots. */
const BOT_TYPING_TIMEOUT_MS = 60_000
const stopBotTyping = () => {
loading.value = false
if (botTypingTimeout) {
clearTimeout(botTypingTimeout)
botTypingTimeout = null
}
}
const startBotTyping = () => {
loading.value = true
if (botTypingTimeout) clearTimeout(botTypingTimeout)
botTypingTimeout = setTimeout(stopBotTyping, BOT_TYPING_TIMEOUT_MS)
}
let socket: Socket | null = null
// socket.io retries transport failures on its own, but not a connection the
// server rejected outright — see the connect_error handler. These drive the
// attempts we have to make ourselves.
const MANUAL_RETRY_BASE_MS = 1000
const MANUAL_RETRY_MAX_MS = 15000
let manualRetryTimer: ReturnType<typeof setTimeout> | null = null
// Refusals that retrying the same connection can't change, so retrying is just
// load: the org has no AI config, or the token isn't bound to a customer yet
// (authorizing again binds one - the caller of connect() does that, see
// `refusal`). Anything else, including a transient failure inside the connect
// handler, is worth another attempt.
const FINAL_REFUSALS = new Set<SocketError['type']>(['ai_config_missing', 'customer_required'])
let refusedForGood = false
// Why the last attempt was refused for good, for the caller of connect().
const refusal = ref<SocketError['type'] | null>(null)
const clearManualRetry = () => {
if (manualRetryTimer) {
clearTimeout(manualRetryTimer)
manualRetryTimer = null
}
}
const scheduleManualRetry = () => {
if (manualRetryTimer || refusedForGood) return
const delay = Math.min(
MANUAL_RETRY_BASE_MS * 2 ** Math.max(0, retryCount.value - 1),
MANUAL_RETRY_MAX_MS
)
manualRetryTimer = setTimeout(() => {
manualRetryTimer = null
socket?.connect()
}, delay)
}
let onTakeoverCallback: ((data: { session_id: string, user_name: string }) => void) | null = null
let onWorkflowStateCallback: ((data: any) => void) | null = null
let onWorkflowProceededCallback: ((data: any) => void) | null = null
let onSessionStateCallback: ((data: SessionState) => void) | null = null
// The server announces the conversation the moment the socket connects, which is
// before the widget has finished starting up and registered its listener - so the
// last announcement is held and replayed to a late subscriber.
let lastSessionState: SessionState | null = null
let widgetToken: string | undefined = undefined
let widgetIdAuth: string | undefined = undefined
// The visitor (token `sub`) the live connection authenticated as.
let connectedVisitor: string | undefined = undefined
const visitorOf = (token: string | undefined | null) => readTokenClaims(token ?? null)?.sub as string | undefined
// Store token for socket authentication.
// A rotated token has to reach the open connection too: the server keeps the
// token the socket connected with, and that copy is what a reconnect presents.
const setToken = (token: string | undefined) => {
widgetToken = token
if (!token) return
if (socket?.connected) {
socket.emit('refresh_token', { conversation_token: token })
}
}
// Store widget ID for socket authentication (for anonymous access)
const setWidgetId = (widgetId: string | undefined) => {
widgetIdAuth = widgetId
}
/** Auth payload for one connection attempt, read fresh each time. */
const buildAuth = () => {
// Use passed token first, then stored token, then localStorage
const token = widgetToken || localStorage.getItem('rpid')
// Build auth object with token and widget_id
const auth: any = {}
if (token) {
auth.conversation_token = token
}
if (widgetIdAuth) {
auth.widget_id = widgetIdAuth
}
// Host page embedding the widget, recorded server-side on lead capture
// (lead_source.page_url). The widget runs in a srcdoc iframe, so the parent
// page is same-origin when embedded via replypier.js; fall back to referrer.
try {
auth.page_url = (window.parent !== window && window.parent.location?.href)
? window.parent.location.href
: (document.referrer || window.location.href)
} catch {
auth.page_url = document.referrer || ''
}
return auth
}
const initializeSocket = (sessionId: string) => {
socket = io(`${widgetEnv.WS_URL}/widget`, {
transports: ['websocket'],
reconnection: true,
// Keep trying. The old cap of 5 attempts was ~15s of backoff, so a
// backend restart or a laptop waking from sleep left the widget dead
// until the visitor reloaded the page.
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 15000,
// Called before every attempt, so a token refreshed since the socket
// was created is the one that gets sent.
auth: (cb: (data: object) => void) => cb(buildAuth())
})
// Set up event listeners
socket.on('connect', () => {
connectedVisitor = visitorOf(buildAuth().conversation_token)
connectionStatus.value = 'connected'
clearManualRetry()
retryCount.value = 0
})
// A reply is actually being produced now.
socket.on('bot_typing', () => {
startBotTyping()
})
socket.on('disconnect', () => {
// Nothing can arrive to clear it while we are disconnected.
stopBotTyping()
if (connectionStatus.value === 'connected') {
console.log('Socket disconnected, setting connection status to connecting')
connectionStatus.value = 'connecting'
}
})
// A refusal carries its reason in `data`: events the server emits before
// refusing never reach the client.
socket.on('connect_error', (err?: Error & { data?: SocketError }) => {
const reason = err?.data?.type
if (reason && FINAL_REFUSALS.has(reason)) {
refusedForGood = true
refusal.value = reason
clearManualRetry()
connectionStatus.value = 'failed'
// A missing customer is ours to fix, not the visitor's to read.
if (reason !== 'customer_required') handleError(err!.data)
return
}
retryCount.value++
console.error('Socket connection failed, attempt:', retryCount.value)
// Offer the manual retry once it has been failing for a while. The
// automatic attempts carry on underneath — 'connect' puts this back to
// 'connected' as soon as one of them lands.
if (retryCount.value >= MAX_RETRIES) {
connectionStatus.value = 'failed'
}
// `active` is false when the server rejected the connection itself
// rather than the transport failing, and socket.io does not retry
// those. One transient error inside the connect handler would
// otherwise wedge the widget on "Connecting..." until a page reload.
if (socket && !socket.active) {
scheduleManualRetry()
}
})
socket.on('chat_response', (data) => {
stopBotTyping() // the reply we were waiting for has arrived
// Capture session_id from response for file attachments
if (data.session_id) {
console.log('Captured session_id from chat_response:', data.session_id)
currentSessionId.value = data.session_id
} else {
console.warn('No session_id in chat_response data:', data)
}
if (data.type === 'agent_message') {
// Handle human agent messages
const agentMessage: any = {
message: data.message,
message_type: 'agent',
created_at: new Date().toISOString(),
session_id: '',
agent_name: data.agent_name,
stream: true, // live reply → client-side typewriter reveal
attributes: {
end_chat: data.end_chat,
end_chat_reason: data.end_chat_reason,
end_chat_description: data.end_chat_description,
request_rating: data.request_rating
}
}
// Add attachments if present
if (data.attachments && Array.isArray(data.attachments)) {
agentMessage.id = data.message_id
agentMessage.attachments = data.attachments.map((att: any, idx: number) => ({
id: data.message_id * 1000 + idx,
filename: att.filename,
file_url: att.file_url,
content_type: att.content_type,
file_size: att.file_size
}))
}
messages.value.push(agentMessage)
// UPDATED CHECK: Look for the shopify_output object and products array
} else if (data.shopify_output && typeof data.shopify_output === 'object' && data.shopify_output.products) {
// Handle structured Shopify product data
messages.value.push({
message: data.message, // Keep the accompanying text message
message_type: 'product', // Use 'product' type for rendering
created_at: new Date().toISOString(),
session_id: '',
agent_name: data.agent_name,
// Assign the whole structured object
shopify_output: data.shopify_output,
// Remove the old flattened fields (product_id, product_title, etc.)
attributes: { // Keep other attributes if needed
end_chat: data.end_chat,
request_rating: data.request_rating
}
})
} else {
// Handle regular bot messages (without Shopify data)
messages.value.push({
message: data.message,
message_type: 'bot',
created_at: new Date().toISOString(),
session_id: '',
agent_name: data.agent_name,
stream: true, // live reply → client-side typewriter reveal
// Knowledge-base citations (display gated by show_citations in the widget)
sources: Array.isArray(data.sources) && data.sources.length ? data.sources : undefined,
attributes: {
end_chat: data.end_chat,
end_chat_reason: data.end_chat_reason,
end_chat_description: data.end_chat_description,
request_rating: data.request_rating
}
})
}
// loading.value = false // Moved to the top
})
socket.on('handle_taken_over', (data: { session_id: string, user_name: string, profile_picture?: string }) => {
// Add system message for takeover
messages.value.push({
message: `${data.user_name} 加入了会话`,
message_type: 'system',
created_at: new Date().toISOString(),
session_id: data.session_id
})
humanAgent.value = {
...humanAgent.value,
human_agent_name: data.user_name,
human_agent_profile_pic: data.profile_picture
}
// Whatever we were waiting on, it is not coming: the bot is out of
// this conversation now. Without this the typing dots spin forever
// above the "joined the conversation" line.
stopBotTyping()
// Call the callback if registered
if (onTakeoverCallback) {
onTakeoverCallback(data)
}
})
socket.on('session_initialized', (data) => {
// Capture session_id immediately upon connection
if (data.session_id) {
currentSessionId.value = data.session_id
// Report every connection's conversation: the host page needs to know
// when a new one was created (a visitor starting a new chat used to
// happen with no signal at all) and whether it is still authenticated.
lastSessionState = {
session_id: data.session_id,
authenticated: !!data.authenticated,
created: !!data.created
}
onSessionStateCallback?.(lastSessionState)
}
})
socket.on('error', handleError)
socket.on('chat_history', handleChatHistory)
socket.on('rating_submitted', handleRatingSubmitted)
socket.on('display_form', handleDisplayForm)
socket.on('form_submitted', handleFormSubmitted)
socket.on('workflow_state', handleWorkflowState)
socket.on('workflow_proceeded', handleWorkflowProceeded)
return socket
}
const connect = async (): Promise<boolean> => {
try {
// Keep a live connection that is already this visitor's. Tearing it down
// loses whatever the server emits before the new one joins the room - a
// human agent's first reply right after a takeover (which re-runs the
// widget's authorization) vanished that way, and history is only loaded
// once. A rotated token for the same visitor already reached the open
// connection via refresh_token; another visitor still gets a new one.
if (socket?.connected && connectedVisitor && connectedVisitor === visitorOf(buildAuth().conversation_token)) {
connectionStatus.value = 'connected'
return true
}
connectionStatus.value = 'connecting'
retryCount.value = 0
// Cleanup existing socket if any
stopBotTyping()
clearManualRetry()
refusedForGood = false
refusal.value = null
if (socket) {
socket.removeAllListeners()
socket.disconnect()
socket = null
}
socket = initializeSocket('')
return new Promise((resolve) => {
socket?.on('connect', () => {
resolve(true)
})
// Registered after initializeSocket's handler, so a refusal is
// already recorded when this runs. A refused connect has to settle:
// the widget's startup awaits it.
socket?.on('connect_error', () => {
if (refusedForGood || retryCount.value >= MAX_RETRIES) {
resolve(false)
}
})
})
} catch (error) {
console.error('Socket initialization failed:', error)
connectionStatus.value = 'failed'
return false
}
}
// Manual reconnect function
const reconnect = () => {
if (socket) {
socket.disconnect()
}
return connect()
}
// Register takeover callback
const onTakeover = (callback: (data: { session_id: string, user_name: string }) => void) => {
onTakeoverCallback = callback
}
// Register conversation-state callback
const onSessionState = (callback: (data: SessionState) => void) => {
onSessionStateCallback = callback
if (lastSessionState) callback(lastSessionState)
}
// Register workflow state callback
const onWorkflowState = (callback: (data: any) => void) => {
onWorkflowStateCallback = callback
}
// Register workflow proceeded callback
const onWorkflowProceeded = (callback: (data: any) => void) => {
onWorkflowProceededCallback = callback
}
// Socket event handlers
const handleError = (error: any) => {
stopBotTyping()
errorMessage.value = getErrorMessage(error as SocketError)
showError.value = true
// Hide error after 5 seconds
setTimeout(() => {
showError.value = false
errorMessage.value = ''
}, 5000)
}
const handleChatHistory = (data: {
type: string;
messages: Message[];
}) => {
if (data.type === 'chat_history' && Array.isArray(data.messages)) {
const historyMessages = data.messages.map((msg: Message) => {
// Base message structure
const messageObj = {
message: msg.message,
message_type: msg.message_type as "assistant" | "user" | "error" | "bot" | "agent" | "system" | "product",
created_at: msg.created_at,
session_id: '',
agent_name: msg.agent_name || '',
user_name: msg.user_name || '',
attributes: msg.attributes || {},
attachments: msg.attachments || [] // Include attachments
}
// Restore knowledge-base citations persisted in attributes
if (Array.isArray(msg.attributes?.sources) && msg.attributes.sources.length) {
;(messageObj as Message).sources = msg.attributes.sources
}
// Check if message has Shopify data in attributes
if (msg.attributes?.shopify_output && typeof msg.attributes.shopify_output === 'object') {
return {
...messageObj,
message_type: 'product',
shopify_output: msg.attributes.shopify_output
}
}
return messageObj
})
messages.value = [
...historyMessages.filter(newMsg =>
!messages.value.some(existingMsg =>
existingMsg.message === newMsg.message &&
existingMsg.created_at === newMsg.created_at
)
),
...messages.value
]
}
}
// Add rating submission handler
const handleRatingSubmitted = (data: { success: boolean, message: string }) => {
if (data.success) {
messages.value.push({
message: '感谢你的反馈!',
message_type: 'system',
created_at: new Date().toISOString(),
session_id: ''
})
}
}
// Form display handler
const handleDisplayForm = (data: { form_data: any, session_id: string }) => {
console.log('Form display handler in composable:', data)
stopBotTyping()
currentForm.value = data.form_data
console.log('Set currentForm in handleDisplayForm:', currentForm.value)
// Check if this is a full screen form
if (data.form_data?.form_full_screen === true) {
console.log('Full screen form detected, triggering workflow state callback')
// Trigger workflow state callback for full screen forms
if (onWorkflowStateCallback) {
onWorkflowStateCallback({
type: 'form',
form_data: data.form_data,
session_id: data.session_id
})
}
} else {
// Add form message to chat for regular forms
messages.value.push({
message: '',
message_type: 'form',
created_at: new Date().toISOString(),
session_id: data.session_id,
attributes: {
form_data: data.form_data
}
})
}
}
// Form submission confirmation handler
const handleFormSubmitted = (data: { success: boolean, message: string }) => {
console.log('Form submitted confirmation received, clearing currentForm')
currentForm.value = null
if (data.success) {
// Success message will come through regular chat_response
console.log('Form submitted successfully')
}
}
// Workflow state handler
const handleWorkflowState = (data: any) => {
console.log('Workflow state received in composable:', data)
// Set currentForm for form states to ensure submission works
if (data.type === 'form' || data.type === 'display_form') {
console.log('Setting currentForm from workflow state:', data.form_data)
currentForm.value = data.form_data
}
if (onWorkflowStateCallback) {
onWorkflowStateCallback(data)
}
}
// Workflow proceeded handler
const handleWorkflowProceeded = (data: { success: boolean }) => {
console.log('Workflow proceeded in composable:', data)
if (onWorkflowProceededCallback) {
onWorkflowProceededCallback(data)
}
}
// Add rating submission function
const submitRating = async (rating: number, feedback?: string) => {
if (!socket || !rating) return
socket.emit('submit_rating', {
rating,
feedback
})
}
// Form submission function
const submitForm = async (formData: Record<string, any>) => {
console.log('Submitting form in socket:', formData)
console.log('Current form in socket:', currentForm.value)
console.log('Socket in socket:', socket)
if (!socket) {
console.error('No socket available for form submission')
return
}
// Allow submission even if currentForm.value is null, as long as we have form data
if (!formData || Object.keys(formData).length === 0) {
console.error('No form data to submit')
return
}
// Handoff contact-capture forms go to a dedicated handler (updates the customer
// record) instead of the workflow form pipeline.
const isContactForm = currentForm.value?.form_type === 'contact'
const event = isContactForm ? 'submit_contact_info' : 'submit_form'
console.log(`Emitting ${event} event with data:`, formData)
socket.emit(event, {
form_data: formData
})
// Clear current form after submission
currentForm.value = null
}
// Get workflow state function
const getWorkflowState = async () => {
if (!socket) return
console.log('Getting workflow state 12')
socket.emit('get_workflow_state')
}
// Proceed workflow function
const proceedWorkflow = async () => {
if (!socket) return
socket.emit('proceed_workflow', {})
}
// Send message function
const sendMessage = async (newMessage: string, email: string, files: Array<{content: string, filename: string, content_type: string, size: number}> = []) => {
if (!socket || (!newMessage.trim() && files.length === 0)) return
// Add user message to display with temporary blob URLs for images
const userMessage: any = {
message: newMessage,
message_type: 'user',
created_at: new Date().toISOString(),
session_id: ''
}
// Add temporary attachments for immediate display (will be replaced with real URLs from backend)
if (files.length > 0) {
userMessage.attachments = files.map((file, idx) => {
// Create temporary blob URL for images
let tempUrl = ''
if (file.content_type.startsWith('image/')) {
// Convert base64 to blob URL for immediate display
const byteCharacters = atob(file.content)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], { type: file.content_type })
tempUrl = URL.createObjectURL(blob)
}
return {
id: Date.now() * 1000 + idx, // Temporary ID
filename: file.filename,
file_url: tempUrl, // Temporary blob URL, will be replaced
content_type: file.content_type,
file_size: file.size,
_isTemporary: true // Flag to identify temporary attachments
}
})
}
messages.value.push(userMessage)
// Emit to socket WITH files (files will be uploaded on backend)
socket.emit('chat', {
message: newMessage,
email: email,
files: files // Send files with base64 content
})
hasStartedChat.value = true
}
/**
* Close the current session server-side and reset local conversation state.
*
* History is scoped to the ACTIVE session (see get_chat_history), so closing it
* is what makes the next conversation genuinely new rather than a reload of the
* old one. Resolves when the server confirms, or after a short grace period so a
* dropped confirmation can't leave the visitor staring at a dead button.
*/
const resetConversationState = () => {
messages.value = []
hasStartedChat.value = false
currentSessionId.value = ''
// A reply in flight never lands once the session closes, so the typing
// indicator would spin forever over an empty chat.
stopBotTyping()
currentForm.value = null
}
// Must be a member of EndChatReasonType (backend/app/models/session_to_agent.py):
// the column is an enum, and an unknown value makes close_session roll back and
// silently leave the session open.
const endChat = (reason = 'CUSTOMER_REQUEST'): Promise<boolean> => {
return new Promise((resolve) => {
if (!socket || !socket.connected) {
// Nothing was closed server-side, so the session is still open and
// will come back on the next connect. Say so rather than clearing the
// transcript and calling it a new chat.
resolve(false)
return
}
let settled = false
const settle = (closed: boolean) => {
if (settled) return
settled = true
clearTimeout(timer)
socket?.off('chat_ended', onClosed)
socket?.off('error', onFailed)
if (closed) resetConversationState()
resolve(closed)
}
const onClosed = () => settle(true)
const onFailed = (error: any) => {
if (error?.type === 'end_chat_error') settle(false)
}
// Nothing confirmed in this long means the close did not happen.
const timer = setTimeout(() => settle(false), END_CHAT_TIMEOUT_MS)
socket.on('chat_ended', onClosed)
socket.on('error', onFailed)
socket.emit('end_chat', { reason })
})
}
// Chat history functions
const loadChatHistory = async () => {
if (!socket) return
try {
loadingHistory.value = true
socket.emit('get_chat_history')
} catch (error) {
console.error('Failed to load chat history:', error)
} finally {
loadingHistory.value = false
}
}
const cleanup = () => {
// Before removeAllListeners, or the disconnect handler that would have
// done it never fires and the timer outlives the widget.
stopBotTyping()
clearManualRetry()
if (socket) {
socket.removeAllListeners()
socket.disconnect()
socket = null
}
onTakeoverCallback = null
onWorkflowStateCallback = null
onWorkflowProceededCallback = null
}
return {
messages,
loading,
errorMessage,
showError,
loadingHistory,
hasStartedChat,
connectionStatus,
sendMessage,
endChat,
loadChatHistory,
connect,
reconnect,
cleanup,
humanAgent,
onTakeover,
onSessionState,
refusal,
submitRating,
currentForm,
submitForm,
getWorkflowState,
proceedWorkflow,
onWorkflowState,
onWorkflowProceeded,
currentSessionId,
setToken,
setWidgetId
}
}