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

@@ -0,0 +1,73 @@
<!--
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.
-->
<script setup lang="ts">
import { computed } from 'vue'
// The channel's delivery receipt for a message we sent. 'failed' has its own
// "Not delivered" notice, so it shows no tick.
type ChannelStatus = 'sent' | 'delivered' | 'read'
const TICKS: Record<ChannelStatus, { icon: string; label: string }> = {
sent: { icon: 'fa-solid fa-check', label: '已发送' },
delivered: { icon: 'fa-solid fa-check-double', label: '已送达' },
read: { icon: 'fa-solid fa-check-double', label: '已读' },
}
const props = defineProps<{
status?: string | null
// On an accent-filled bubble no blue holds contrast in either theme, so
// read becomes an inverted badge in the bubble's own colour pair
onAccent?: boolean
}>()
const tick = computed(() =>
props.status && props.status in TICKS ? TICKS[props.status as ChannelStatus] : null
)
</script>
<template>
<span
v-if="tick"
class="status-ticks"
:class="[`status-ticks--${status}`, { 'status-ticks--on-accent': onAccent }]"
:title="tick.label"
:aria-label="tick.label"
role="img"
>
<font-awesome-icon :icon="tick.icon" />
</span>
</template>
<style scoped>
.status-ticks {
display: inline-flex;
margin-left: 4px;
font-size: var(--text-xs);
vertical-align: baseline;
}
.status-ticks--read {
color: var(--c-info);
}
.status-ticks--on-accent.status-ticks--read {
padding: 0 4px;
border-radius: var(--radius-full);
background: var(--bubble-agent-fg);
color: var(--bubble-agent-bg);
}
</style>
@@ -0,0 +1,62 @@
<!--
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.
-->
<script setup lang="ts">
import { computed } from 'vue'
// The choices a message went out with (the backend's record of what was
// actually sent). Read-only: they were tapped on the customer's phone.
interface SentChoices {
as_text?: boolean
choices?: Array<{ id: string; text: string }>
}
const props = defineProps<{
sent?: SentChoices | null
}>()
const choices = computed(() => props.sent?.choices ?? [])
const label = computed(() =>
props.sent?.as_text ? '已发送的选项(以编号列表发送)' : '已发送的选项'
)
</script>
<template>
<ul v-if="choices.length" class="quick-reply-chips" :aria-label="label" :title="label">
<li v-for="choice in choices" :key="choice.id" class="quick-reply-chip">{{ choice.text }}</li>
</ul>
</template>
<style scoped>
.quick-reply-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 8px 0 0;
padding: 0;
list-style: none;
}
.quick-reply-chip {
padding: 3px 10px;
border: 1px solid var(--border-color);
border-radius: var(--radius-full);
background: var(--background-soft);
color: var(--text-color);
font-size: var(--text-xs);
line-height: 1.5;
}
</style>
@@ -0,0 +1,170 @@
/*
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 { ref, type Ref } from 'vue'
import type { ChatDetail } from '@/types/chat'
import FileUpload from '@/components/common/FileUpload.vue'
import { getApiUrl } from '@/config/api'
import { buildAttachmentUrl } from '@/utils/avatars'
import { newClientId } from '@/utils/messageMatch'
export function useConversationFiles(
currentChat: Ref<ChatDetail>,
newMessage: Ref<string>,
canSendMessage: Ref<boolean>,
scrollToBottom: () => void
) {
// File upload refs
const fileUploadRef = ref<InstanceType<typeof FileUpload> | null>(null)
const uploadedFiles = ref<Array<{content: string, filename: string, content_type: string, size: number}>>([])
// Handle file uploads
const handleFilesUploaded = (files: Array<{content: string, filename: string, content_type: string, size: number}>) => {
uploadedFiles.value = files
}
const handleFileUploadError = (error: string) => {
console.error('File upload error:', error)
}
// Handle paste events for screenshots
const handleChatPaste = (event: ClipboardEvent) => {
if (fileUploadRef.value) {
fileUploadRef.value.handlePaste(event)
}
}
// Wrapper to send message with attachments
const handleSendMessageWithAttachments = async () => {
if (!newMessage.value.trim() && uploadedFiles.value.length === 0) return
if (!canSendMessage.value) return
try {
const messageText = newMessage.value
const socketService = (await import('@/services/socket')).socketService
// Clear input immediately for better UX
newMessage.value = ''
// Add message locally first
const timestamp = new Date().toISOString()
// Ties this optimistic message to the row the backend stores, so its
// delivery receipts and errors find it exactly (agent_message_stored)
const clientId = newClientId()
const localMessage: any = {
message: messageText,
message_type: 'agent',
created_at: timestamp,
session_id: currentChat.value.session_id,
client_id: clientId
}
// Add temporary attachments for immediate display
if (uploadedFiles.value.length > 0) {
localMessage.attachments = uploadedFiles.value.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
content_type: file.content_type,
file_size: file.size,
_isTemporary: true // Flag to identify temporary attachments
}
})
}
// Update chat with new message
currentChat.value.messages.push(localMessage)
currentChat.value.updated_at = timestamp
// Prepare files for upload (if any)
const files = uploadedFiles.value.map(f => ({
content: f.content,
filename: f.filename,
content_type: f.content_type,
size: f.size
}))
// Emit message with files through socket
socketService.emit('agent_message', {
message: messageText,
session_id: currentChat.value.session_id,
message_type: 'agent',
created_at: timestamp,
client_id: clientId,
files: files // Send files with the message
})
scrollToBottom()
// Clear uploaded files after sending
if (uploadedFiles.value.length > 0) {
if (fileUploadRef.value) {
fileUploadRef.value.clearFiles()
}
uploadedFiles.value = []
}
} catch (err) {
console.error('Failed to send message:', err)
}
}
// Format file size for display
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// Check if attachment is an image
const isImageAttachment = (contentType: string): boolean => {
return contentType.startsWith('image/')
}
// Attachment links and image sources resolve the same way: see buildAttachmentUrl.
const getDownloadUrl = (fileUrl: string | undefined | null): string =>
buildAttachmentUrl(fileUrl, getApiUrl())
const getImageUrl = getDownloadUrl
return {
fileUploadRef,
uploadedFiles,
handleFilesUploaded,
handleFileUploadError,
handleChatPaste,
handleSendMessageWithAttachments,
formatFileSize,
isImageAttachment,
getDownloadUrl,
getImageUrl
}
}
@@ -0,0 +1,255 @@
/*
* 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 { ref, type Ref } from 'vue'
import { widgetEnv } from '../webclient/widget-env'
/**
* The widget's conversation token: where it is kept, and how it stays alive.
*
* An identified token (POST /generate-token) lives for as long as the embedding app
* asked for — an hour by default. A visitor who leaves the page open longer than that
* used to lose their identity the moment anything re-checked the token, and came back
* as a brand-new anonymous customer. So the widget rotates the token before it lapses,
* and treats a lapsed one as something only the host page can fix.
*/
export const TOKEN_STORAGE_KEY = 'ctid'
// Matches conversation_token.IDENTITY_EXPIRED_CODE on the backend: the request
// carried a real token for a signed-in visitor, and it has run out.
export const IDENTITY_EXPIRED_CODE = 'identity_expired'
// Rotate once this share of the lifetime is gone. Early enough to survive a sleeping
// tab that wakes just before expiry, late enough not to churn tokens on every visit.
const REFRESH_AT_LIFETIME_FRACTION = 0.8
// setTimeout clamps anything past ~24.8 days to a negative delay and fires at once.
const MAX_TIMER_MS = 12 * 60 * 60 * 1000
// A token that is already past its refresh point would otherwise be retried with no
// delay at all, which turns a backend hiccup into a request storm.
const RETRY_DELAY_MS = 30 * 1000
const SECOND_MS = 1000
/** Reject the strings an auth store stringifies into when it has nothing. */
export const sanitizeToken = (value: unknown): string | null => {
if (typeof value !== 'string') return value ? String(value) : null
const trimmed = value.trim()
if (!trimmed || trimmed === 'undefined' || trimmed === 'null') return null
return trimmed
}
/** Claims of a JWT we already hold. Read-only use: the server still verifies it. */
export const readTokenClaims = (token: string | null): Record<string, any> | null => {
const value = sanitizeToken(token)
if (!value) return null
const [, payload] = value.split('.')
if (!payload) return null
try {
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'))
return JSON.parse(json)
} catch {
return null
}
}
const readStoredToken = (): string | null => {
try {
return sanitizeToken(localStorage.getItem(TOKEN_STORAGE_KEY))
} catch {
// Private mode, or storage blocked by the host page's settings.
return null
}
}
const writeStoredToken = (token: string) => {
try {
localStorage.setItem(TOKEN_STORAGE_KEY, token)
} catch {
// Not fatal: the token still lives in memory for this page view.
}
}
const clearStoredToken = () => {
try {
localStorage.removeItem(TOKEN_STORAGE_KEY)
} catch {
/* nothing to clear */
}
}
export interface ConversationTokenOptions {
/** Called whenever the token changes, so the host page can store the new one. */
onTokenChanged?: (token: string) => void
/** Called when only the host can help: its token expired and cannot be rotated. */
onIdentityExpired?: () => void
}
export function useConversationToken(options: ConversationTokenOptions = {}) {
const token: Ref<string | null> = ref(null)
let refreshTimer: ReturnType<typeof setTimeout> | null = null
let inFlight: Promise<boolean> | null = null
const clearTimer = () => {
if (refreshTimer) {
clearTimeout(refreshTimer)
refreshTimer = null
}
}
/** Seconds until the current token expires; null when it carries no expiry. */
const secondsUntilExpiry = (): number | null => {
const claims = readTokenClaims(token.value)
if (!claims?.exp) return null
return Number(claims.exp) - Math.floor(Date.now() / SECOND_MS)
}
/** True once the token is close enough to expiry to be worth rotating. */
const needsRefresh = (): boolean => {
const claims = readTokenClaims(token.value)
if (!claims?.exp) return false
const lifetime = claims.iat ? Number(claims.exp) - Number(claims.iat) : 0
const remaining = secondsUntilExpiry() ?? 0
if (lifetime <= 0) return remaining <= 0
return remaining <= lifetime * (1 - REFRESH_AT_LIFETIME_FRACTION)
}
const setToken = (next: string | null, { persist = true } = {}) => {
const clean = sanitizeToken(next)
clearTimer()
token.value = clean
if (!clean) {
clearStoredToken()
return
}
if (persist) {
writeStoredToken(clean)
options.onTokenChanged?.(clean)
}
scheduleRefresh()
}
/**
* Rotate the token now. Resolves true when the widget holds a usable token
* afterwards — including when the current one was still fine.
*/
const refresh = async (widgetId: string): Promise<boolean> => {
if (!token.value || !widgetId) return false
if (inFlight) return inFlight
const current = token.value
inFlight = (async () => {
try {
const response = await fetch(`${widgetEnv.API_URL}/refresh-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${current}`,
},
body: JSON.stringify({ widget_id: widgetId }),
})
if (response.status === 401) {
// Past saving from in here: only the host page can mint a new identity.
options.onIdentityExpired?.()
return false
}
if (!response.ok) {
scheduleRefresh(RETRY_DELAY_MS)
return false
}
const body = await response.json()
const rotated = sanitizeToken(body?.data?.token)
if (!rotated) return false
setToken(rotated)
return true
} catch {
// Offline or blocked: keep the token we have and try again shortly.
scheduleRefresh(RETRY_DELAY_MS)
return false
} finally {
inFlight = null
}
})()
return inFlight
}
/** Rotate only if the token is near the end of its life. */
const ensureFresh = async (widgetId: string): Promise<boolean> => {
if (!token.value) return false
if (!needsRefresh()) return true
return refresh(widgetId)
}
/**
* Queue the next rotation. Anonymous tokens are long-lived and carry no `iat`
* lifetime worth tracking, so only identified ones are scheduled.
*/
const scheduleRefresh = (delayMs?: number) => {
clearTimer()
const claims = readTokenClaims(token.value)
if (!claims?.exp || !claims?.iat) return
const lifetimeMs = (Number(claims.exp) - Number(claims.iat)) * SECOND_MS
const remainingMs = (secondsUntilExpiry() ?? 0) * SECOND_MS
const delay = delayMs ?? Math.min(
MAX_TIMER_MS,
Math.max(0, remainingMs - lifetimeMs * (1 - REFRESH_AT_LIFETIME_FRACTION)),
)
refreshTimer = setTimeout(() => {
refreshTimer = null
// ensureFresh, not refresh: the timer is capped at MAX_TIMER_MS, so on a
// long-lived token this tick only re-arms rather than rotating early.
void ensureFresh(scheduledWidgetId).then((ok) => {
// Re-arm either way: a token that was not due yet still needs its next tick,
// and a failed rotation should be retried rather than abandoned.
scheduleRefresh(ok ? undefined : RETRY_DELAY_MS)
})
}, delay)
}
// Captured once at start(): the widget only ever serves one widget id per page.
let scheduledWidgetId = ''
/** Adopt the token the backend rendered into the frame, or the stored one. */
const start = (widgetId: string, initialToken?: unknown) => {
scheduledWidgetId = widgetId
const initial = sanitizeToken(initialToken) || readStoredToken()
// Persisting on start would echo the token straight back to the host page, which
// already has it. setToken still arms the refresh timer.
setToken(initial, { persist: false })
if (initial) writeStoredToken(initial)
}
const stop = () => {
clearTimer()
inFlight = null
}
return {
token,
start,
stop,
setToken,
ensureFresh,
}
}
+811
View File
@@ -0,0 +1,811 @@
/*
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 { 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('ctid')
// 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 chattermate.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
}
}
+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 }
}