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