2026-09-26 22:20:00 +08:00
|
|
|
/*
|
|
|
|
|
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'
|
2026-10-29 09:46:21 +08:00
|
|
|
import { io } from 'socket.io-client'
|
|
|
|
|
import type { Socket } from 'socket.io-client'
|
|
|
|
|
import type { HumanAgent, SocketError, WidgetFormData, WidgetMessage } from '../types/widget'
|
2026-09-26 22:20:00 +08:00
|
|
|
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() {
|
2026-10-29 09:46:21 +08:00
|
|
|
const messages = ref<WidgetMessage[]>([])
|
2026-09-26 22:20:00 +08:00
|
|
|
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>({})
|
2026-10-29 09:46:21 +08:00
|
|
|
const currentForm = ref<WidgetFormData | null>(null)
|
2026-09-26 22:20:00 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-29 09:46:21 +08:00
|
|
|
const initializeSocket = () => {
|
2026-09-26 22:20:00 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-29 09:46:21 +08:00
|
|
|
socket = initializeSocket()
|
2026-09-26 22:20:00 +08:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-10-29 09:46:21 +08:00
|
|
|
}
|