feat: add support chat widget demo and state management

- Implemented SupportWidgetDemo component for configuring and mounting the support chat widget.
- Introduced Zustand store for managing support chat state, including message handling and socket connection.
- Created support host bridge for communication between the widget and parent window.
- Added utility functions for JWT token generation and local storage management.
- Enhanced user experience with notifications for new messages and chat status updates.
This commit is contained in:
mlogclub
2026-05-30 14:00:23 +08:00
parent bcab31eaa8
commit 86ff71596d
28 changed files with 139 additions and 139 deletions
+4 -4
View File
@@ -188,7 +188,7 @@ flowchart TD
│ └── ai/ # LLM / RAG / MCP related logic │ └── ai/ # LLM / RAG / MCP related logic
├── web/ # unified Next.js frontend ├── web/ # unified Next.js frontend
│ ├── app/dashboard/ # admin dashboard │ ├── app/dashboard/ # admin dashboard
│ ├── app/kefu/ # customer service entry and chat pages │ ├── app/support/ # customer service entry and chat pages
│ ├── components/ # React components │ ├── components/ # React components
│ ├── lib/ # API client, SDK source and utilities │ ├── lib/ # API client, SDK source and utilities
│ ├── public/sdk/ # built embeddable SDK assets │ ├── public/sdk/ # built embeddable SDK assets
@@ -255,8 +255,8 @@ make web-dev
- 管理后台:`http://localhost:3000/dashboard` - 管理后台:`http://localhost:3000/dashboard`
- 客服工作台:`http://localhost:3000/dashboard/conversations` - 客服工作台:`http://localhost:3000/dashboard/conversations`
- 客户侧 Web 接入示例:`http://localhost:3000/kefu` - 客户侧 Web 接入示例:`http://localhost:3000/support/demo`
- 客户侧聊天页:`http://localhost:3000/kefu/chat` - 客户侧聊天页:`http://localhost:3000/support/chat`
生产构建时,前端统一由 `web` 工程构建,静态产物输出到 `web/out`,后端会从 `web/out` 提供静态资源。 生产构建时,前端统一由 `web` 工程构建,静态产物输出到 `web/out`,后端会从 `web/out` 提供静态资源。
@@ -308,7 +308,7 @@ docker run --rm -p 8083:8083 \
- 管理后台:负责 AI Agent、知识库、客服组、工单与运营配置 - 管理后台:负责 AI Agent、知识库、客服组、工单与运营配置
- 客服工作台:负责接管会话、处理消息与人工服务 - 客服工作台:负责接管会话、处理消息与人工服务
- 客户侧 Web 接入:通过 `/kefu``/kefu/chat``web/public/sdk` 中的嵌入式脚本承接用户咨询入口 - 客户侧 Web 接入:通过 `/support/demo``/support/chat``web/public/sdk` 中的嵌入式脚本承接用户咨询入口
这使得`贝壳AI客服`可以同时覆盖: 这使得`贝壳AI客服`可以同时覆盖:
+1 -1
View File
@@ -1,7 +1,7 @@
server: server:
port: 8083 port: 8083
cors: cors:
# 浏览器跨域白名单。生产环境必须改为实际前端/嵌入站点域名,例如 https://kefu.example.com。 # 浏览器跨域白名单。生产环境必须改为实际前端/嵌入站点域名,例如 https://support.example.com。
# 留空表示不允许跨域请求,只支持同源或非浏览器调用。 # 留空表示不允许跨域请求,只支持同源或非浏览器调用。
allowedOrigins: allowedOrigins:
- http://127.0.0.1:8083 - http://127.0.0.1:8083
+1 -1
Submodule docs updated: e0dc72852c...a86a0d2c03
@@ -7,7 +7,7 @@ import { z } from "zod/v4"
import { CopyIcon, ExternalLinkIcon } from "lucide-react" import { CopyIcon, ExternalLinkIcon } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { getWidgetDemoPath } from "@/components/kefu/demo-navigation" import { getWidgetDemoPath } from "@/components/support-chat/demo-navigation"
import { OptionCombobox } from "@/components/option-combobox" import { OptionCombobox } from "@/components/option-combobox"
import { ProjectDialog } from "@/components/project-dialog" import { ProjectDialog } from "@/components/project-dialog"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
+2 -2
View File
@@ -14,9 +14,9 @@ export default function Page() {
<CheckCircle2Icon className="size-7" /> <CheckCircle2Icon className="size-7" />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<h1 className="text-lg font-semibold text-foreground">{t("kefu.closedTitle")}</h1> <h1 className="text-lg font-semibold text-foreground">{t("supportChat.closedTitle")}</h1>
<p className="text-sm leading-6 text-muted-foreground"> <p className="text-sm leading-6 text-muted-foreground">
{t("kefu.closedDescription")} {t("supportChat.closedDescription")}
</p> </p>
</div> </div>
</section> </section>
+2 -2
View File
@@ -1,5 +1,5 @@
import { KefuChatShell } from "@/components/kefu/chat-shell" import { SupportChatShell } from "@/components/support-chat/chat-shell"
export default function Page() { export default function Page() {
return <KefuChatShell /> return <SupportChatShell />
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import { KefuWidgetDemo } from "@/components/kefu/widget-demo" import { SupportWidgetDemo } from "@/components/support-chat/widget-demo"
export default function Page() { export default function Page() {
return <KefuWidgetDemo /> return <SupportWidgetDemo />
} }
-5
View File
@@ -1,5 +0,0 @@
export const KEFU_CLOSED_PATH = "/support/chat/closed"
export function getStandaloneClosedUrl() {
return KEFU_CLOSED_PATH
}
-5
View File
@@ -1,5 +0,0 @@
export const KEFU_DEMO_PATH = "/support/demo"
export function getWidgetDemoPath() {
return KEFU_DEMO_PATH
}
@@ -20,20 +20,20 @@ import {
} from "react" } from "react"
import { useShallow } from "zustand/react/shallow" import { useShallow } from "zustand/react/shallow"
import { KefuConnectionStatus } from "@/components/kefu/connection-status" import { SupportChatConnectionStatus } from "@/components/support-chat/connection-status"
import { getStandaloneClosedUrl } from "@/components/kefu/close-navigation" import { getStandaloneClosedUrl } from "@/components/support-chat/close-navigation"
import { CustomerMessageEditor } from "@/components/kefu/customer-message-editor" import { CustomerMessageEditor } from "@/components/support-chat/customer-message-editor"
import { import {
KefuMessageList, SupportChatMessageList,
type KefuMessageListHandle, type SupportChatMessageListHandle,
} from "@/components/kefu/message-list" } from "@/components/support-chat/message-list"
import { import {
bindKefuHostBridge, bindSupportHostBridge,
requestKefuHostClose, requestSupportHostClose,
requestKefuHostMinimize, requestSupportHostMinimize,
requestKefuHostToggleMaximize, requestSupportHostToggleMaximize,
} from "@/lib/kefu-host-bridge" } from "@/lib/support-host-bridge"
import { useKefuChatStore } from "@/lib/stores/kefu-chat" import { useSupportChatStore } from "@/lib/stores/support-chat"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@@ -80,7 +80,7 @@ function getMobileStatusDotClass(status: string) {
return "bg-muted-foreground shadow-[0_0_0_3px_rgba(148,163,184,0.14)]" return "bg-muted-foreground shadow-[0_0_0_3px_rgba(148,163,184,0.14)]"
} }
function useKefuSystemTheme() { function useSupportChatSystemTheme() {
useLayoutEffect(() => { useLayoutEffect(() => {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return return
@@ -119,11 +119,11 @@ function isEmbeddedInHost() {
} }
} }
export function KefuChatShell() { export function SupportChatShell() {
const t = useI18n() const t = useI18n()
useKefuSystemTheme() useSupportChatSystemTheme()
const messageListRef = useRef<KefuMessageListHandle | null>(null) const messageListRef = useRef<SupportChatMessageListHandle | null>(null)
const [isEmbedded, setIsEmbedded] = useState(false) const [isEmbedded, setIsEmbedded] = useState(false)
const [isMaximized, setIsMaximized] = useState(false) const [isMaximized, setIsMaximized] = useState(false)
const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false) const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false)
@@ -152,7 +152,7 @@ export function KefuChatShell() {
disconnectSocket, disconnectSocket,
markConversationRead, markConversationRead,
closeConversation, closeConversation,
} = useKefuChatStore( } = useSupportChatStore(
useShallow((state) => ({ useShallow((state) => ({
title: state.title, title: state.title,
subtitle: state.subtitle, subtitle: state.subtitle,
@@ -192,12 +192,12 @@ export function KefuChatShell() {
return return
} }
void markConversationRead().catch((readError) => { void markConversationRead().catch((readError) => {
console.error("Failed to mark kefu conversation read", readError) console.error("Failed to mark support chat conversation read", readError)
}) })
}, [conversation?.id, isVisible, markConversationRead]) }, [conversation?.id, isVisible, markConversationRead])
useEffect(() => { useEffect(() => {
return bindKefuHostBridge({ return bindSupportHostBridge({
onOpen: () => { onOpen: () => {
setIsOpen(true) setIsOpen(true)
setIsVisible(true) setIsVisible(true)
@@ -250,11 +250,11 @@ export function KefuChatShell() {
function handleMinimize() { function handleMinimize() {
setIsVisible(false) setIsVisible(false)
requestKefuHostMinimize() requestSupportHostMinimize()
} }
function handleToggleMaximize() { function handleToggleMaximize() {
requestKefuHostToggleMaximize() requestSupportHostToggleMaximize()
} }
async function confirmCloseConversation() { async function confirmCloseConversation() {
@@ -268,12 +268,12 @@ export function KefuChatShell() {
} }
setIsCloseDialogOpen(false) setIsCloseDialogOpen(false)
if (isEmbedded) { if (isEmbedded) {
requestKefuHostClose() requestSupportHostClose()
} else { } else {
window.location.replace(getStandaloneClosedUrl()) window.location.replace(getStandaloneClosedUrl())
} }
} catch (closeError) { } catch (closeError) {
window.alert(closeError instanceof Error ? closeError.message : t("kefu.closeConversationFailed")) window.alert(closeError instanceof Error ? closeError.message : t("supportChat.closeConversationFailed"))
} finally { } finally {
setIsClosingConversation(false) setIsClosingConversation(false)
} }
@@ -326,8 +326,8 @@ export function KefuChatShell() {
{!isEmbedded && status !== "connected" ? ( {!isEmbedded && status !== "connected" ? (
<WindowActionButton <WindowActionButton
onClick={retry} onClick={retry}
aria-label={t("kefu.retry")} aria-label={t("supportChat.retry")}
title={t("kefu.retry")} title={t("supportChat.retry")}
> >
<RotateCwIcon className="size-4" /> <RotateCwIcon className="size-4" />
</WindowActionButton> </WindowActionButton>
@@ -335,18 +335,18 @@ export function KefuChatShell() {
{isEmbedded ? ( {isEmbedded ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
render={<WindowActionButton aria-label={t("kefu.moreActions")} title={t("kefu.moreActions")} />} render={<WindowActionButton aria-label={t("supportChat.moreActions")} title={t("supportChat.moreActions")} />}
> >
<MoreHorizontalIcon className="size-4" /> <MoreHorizontalIcon className="size-4" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36"> <DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onClick={retry}> <DropdownMenuItem onClick={retry}>
<RotateCwIcon className="size-4" /> <RotateCwIcon className="size-4" />
{t("kefu.retry")} {t("supportChat.retry")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={handleMinimize}> <DropdownMenuItem onClick={handleMinimize}>
<MinusIcon className="size-4" /> <MinusIcon className="size-4" />
{t("kefu.minimize")} {t("supportChat.minimize")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={handleToggleMaximize}> <DropdownMenuItem onClick={handleToggleMaximize}>
{isMaximized ? ( {isMaximized ? (
@@ -354,22 +354,22 @@ export function KefuChatShell() {
) : ( ) : (
<Maximize2Icon className="size-4" /> <Maximize2Icon className="size-4" />
)} )}
{isMaximized ? t("kefu.restoreWindow") : t("kefu.maximize")} {isMaximized ? t("supportChat.restoreWindow") : t("supportChat.maximize")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
variant="destructive" variant="destructive"
onClick={() => setIsCloseDialogOpen(true)} onClick={() => setIsCloseDialogOpen(true)}
> >
<XIcon className="size-4" /> <XIcon className="size-4" />
{t("kefu.closeWindow")} {t("supportChat.closeWindow")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : ( ) : (
<WindowActionButton <WindowActionButton
onClick={() => setIsCloseDialogOpen(true)} onClick={() => setIsCloseDialogOpen(true)}
aria-label={t("kefu.closeChatWindow")} aria-label={t("supportChat.closeChatWindow")}
title={t("kefu.closeChatWindow")} title={t("supportChat.closeChatWindow")}
className="hover:bg-rose-50 hover:text-rose-600 dark:hover:bg-rose-950/45 dark:hover:text-rose-300" className="hover:bg-rose-50 hover:text-rose-600 dark:hover:bg-rose-950/45 dark:hover:text-rose-300"
> >
<XIcon className="size-4" /> <XIcon className="size-4" />
@@ -378,13 +378,13 @@ export function KefuChatShell() {
</div> </div>
<div className="hidden shrink-0 items-center gap-1 sm:flex sm:gap-2"> <div className="hidden shrink-0 items-center gap-1 sm:flex sm:gap-2">
{status !== "connected" ? ( {status !== "connected" ? (
<KefuConnectionStatus status={status} /> <SupportChatConnectionStatus status={status} />
) : null} ) : null}
<div className="flex items-center gap-0.5 rounded-lg bg-background/55 p-0.5 shadow-sm ring-1 ring-border/70 dark:bg-background/25 dark:ring-white/10"> <div className="flex items-center gap-0.5 rounded-lg bg-background/55 p-0.5 shadow-sm ring-1 ring-border/70 dark:bg-background/25 dark:ring-white/10">
<WindowActionButton <WindowActionButton
onClick={retry} onClick={retry}
aria-label={t("kefu.retry")} aria-label={t("supportChat.retry")}
title={t("kefu.retry")} title={t("supportChat.retry")}
> >
<RotateCwIcon className="size-4" /> <RotateCwIcon className="size-4" />
</WindowActionButton> </WindowActionButton>
@@ -392,15 +392,15 @@ export function KefuChatShell() {
<> <>
<WindowActionButton <WindowActionButton
onClick={handleMinimize} onClick={handleMinimize}
aria-label={t("kefu.minimize")} aria-label={t("supportChat.minimize")}
title={t("kefu.minimize")} title={t("supportChat.minimize")}
> >
<MinusIcon className="size-4" /> <MinusIcon className="size-4" />
</WindowActionButton> </WindowActionButton>
<WindowActionButton <WindowActionButton
onClick={handleToggleMaximize} onClick={handleToggleMaximize}
aria-label={isMaximized ? t("kefu.restoreWindow") : t("kefu.maximizeWindow")} aria-label={isMaximized ? t("supportChat.restoreWindow") : t("supportChat.maximizeWindow")}
title={isMaximized ? t("kefu.restoreWindow") : t("kefu.maximizeWindow")} title={isMaximized ? t("supportChat.restoreWindow") : t("supportChat.maximizeWindow")}
> >
{isMaximized ? ( {isMaximized ? (
<Minimize2Icon className="size-4" /> <Minimize2Icon className="size-4" />
@@ -412,8 +412,8 @@ export function KefuChatShell() {
) : null} ) : null}
<WindowActionButton <WindowActionButton
onClick={() => setIsCloseDialogOpen(true)} onClick={() => setIsCloseDialogOpen(true)}
aria-label={t("kefu.closeChatWindow")} aria-label={t("supportChat.closeChatWindow")}
title={t("kefu.closeChatWindow")} title={t("supportChat.closeChatWindow")}
className="hover:bg-rose-50 hover:text-rose-600 dark:hover:bg-rose-950/45 dark:hover:text-rose-300" className="hover:bg-rose-50 hover:text-rose-600 dark:hover:bg-rose-950/45 dark:hover:text-rose-300"
> >
<XIcon className="size-4" /> <XIcon className="size-4" />
@@ -424,7 +424,7 @@ export function KefuChatShell() {
</header> </header>
<div className="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] overflow-hidden bg-muted/60 dark:bg-muted/30"> <div className="grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] overflow-hidden bg-muted/60 dark:bg-muted/30">
<KefuMessageList <SupportChatMessageList
ref={messageListRef} ref={messageListRef}
messages={safeMessages} messages={safeMessages}
onNearBottomVisible={maybeMarkConversationRead} onNearBottomVisible={maybeMarkConversationRead}
@@ -459,9 +459,9 @@ export function KefuChatShell() {
> >
<DialogContent className="max-w-[320px]" showCloseButton={!isClosingConversation}> <DialogContent className="max-w-[320px]" showCloseButton={!isClosingConversation}>
<DialogHeader> <DialogHeader>
<DialogTitle>{t("kefu.closeDialogTitle")}</DialogTitle> <DialogTitle>{t("supportChat.closeDialogTitle")}</DialogTitle>
<DialogDescription className="text-xs leading-5"> <DialogDescription className="text-xs leading-5">
{t("kefu.closeDialogDescription")} {t("supportChat.closeDialogDescription")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@@ -471,7 +471,7 @@ export function KefuChatShell() {
disabled={isClosingConversation} disabled={isClosingConversation}
onClick={() => setIsCloseDialogOpen(false)} onClick={() => setIsCloseDialogOpen(false)}
> >
{t("kefu.continueConversation")} {t("supportChat.continueConversation")}
</Button> </Button>
<Button <Button
type="button" type="button"
@@ -479,7 +479,7 @@ export function KefuChatShell() {
disabled={isClosingConversation} disabled={isClosingConversation}
onClick={() => void confirmCloseConversation()} onClick={() => void confirmCloseConversation()}
> >
{isClosingConversation ? t("kefu.closing") : t("kefu.confirmClose")} {isClosingConversation ? t("supportChat.closing") : t("supportChat.confirmClose")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -0,0 +1,5 @@
export const SUPPORT_CHAT_CLOSED_PATH = "/support/chat/closed"
export function getStandaloneClosedUrl() {
return SUPPORT_CHAT_CLOSED_PATH
}
@@ -4,11 +4,11 @@ import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useI18n } from "@/i18n/provider" import { useI18n } from "@/i18n/provider"
type KefuConnectionStatusProps = { type SupportChatConnectionStatusProps = {
status: "connecting" | "connected" | "disconnected" status: "connecting" | "connected" | "disconnected"
} }
export function KefuConnectionStatus({ status }: KefuConnectionStatusProps) { export function SupportChatConnectionStatus({ status }: SupportChatConnectionStatusProps) {
const t = useI18n() const t = useI18n()
const toneClass = const toneClass =
status === "connected" status === "connected"
@@ -32,7 +32,7 @@ export function KefuConnectionStatus({ status }: KefuConnectionStatusProps) {
: "bg-muted-foreground shadow-[0_0_0_4px_rgba(148,163,184,0.14)]" : "bg-muted-foreground shadow-[0_0_0_4px_rgba(148,163,184,0.14)]"
)} )}
/> />
<span>{t(`kefu.${status}`)}</span> <span>{t(`supportChat.${status}`)}</span>
</Badge> </Badge>
) )
} }
@@ -0,0 +1,5 @@
export const SUPPORT_DEMO_PATH = "/support/demo"
export function getWidgetDemoPath() {
return SUPPORT_DEMO_PATH
}
@@ -20,7 +20,7 @@ import { renderIMMessageHTML } from "@/lib/im-message"
import { cn, formatDateTime } from "@/lib/utils" import { cn, formatDateTime } from "@/lib/utils"
import { useI18n } from "@/i18n/provider" import { useI18n } from "@/i18n/provider"
type KefuMessageListProps = { type SupportChatMessageListProps = {
messages?: ImMessage[] | null messages?: ImMessage[] | null
onNearBottomVisible?: () => void onNearBottomVisible?: () => void
hasMoreOlder?: boolean hasMoreOlder?: boolean
@@ -28,7 +28,7 @@ type KefuMessageListProps = {
onLoadOlder?: () => Promise<void> onLoadOlder?: () => Promise<void>
} }
export type KefuMessageListHandle = { export type SupportChatMessageListHandle = {
scrollToBottom: () => void scrollToBottom: () => void
} }
@@ -50,7 +50,7 @@ function getTimelineLabel(
t: (key: string, values?: Record<string, string | number>) => string t: (key: string, values?: Record<string, string | number>) => string
) { ) {
if (!value) { if (!value) {
return t("kefu.justNow") return t("supportChat.justNow")
} }
const date = new Date(value) const date = new Date(value)
if (Number.isNaN(date.getTime())) { if (Number.isNaN(date.getTime())) {
@@ -62,13 +62,13 @@ function getTimelineLabel(
date.getMinutes() date.getMinutes()
).padStart(2, "0")}` ).padStart(2, "0")}`
if (currentDayKey === todayDayKey) { if (currentDayKey === todayDayKey) {
return t("kefu.todayAt", { time: timeText }) return t("supportChat.todayAt", { time: timeText })
} }
return `${currentDayKey} ${timeText}` return `${currentDayKey} ${timeText}`
} }
export const KefuMessageList = forwardRef<KefuMessageListHandle, KefuMessageListProps>( export const SupportChatMessageList = forwardRef<SupportChatMessageListHandle, SupportChatMessageListProps>(
function KefuMessageList( function SupportChatMessageList(
{ {
messages, messages,
onNearBottomVisible, onNearBottomVisible,
@@ -232,14 +232,14 @@ export const KefuMessageList = forwardRef<KefuMessageListHandle, KefuMessageList
onClick={() => void handleLoadOlder()} onClick={() => void handleLoadOlder()}
className="h-7 rounded-full bg-background/90 text-xs text-muted-foreground shadow-sm hover:bg-background hover:text-sky-700 dark:hover:text-sky-400" className="h-7 rounded-full bg-background/90 text-xs text-muted-foreground shadow-sm hover:bg-background hover:text-sky-700 dark:hover:text-sky-400"
> >
{loadingOlder ? t("kefu.loadingOlder") : t("kefu.loadOlder")} {loadingOlder ? t("supportChat.loadingOlder") : t("supportChat.loadOlder")}
</Button> </Button>
</div> </div>
) : null} ) : null}
{safeMessages.length === 0 ? ( {safeMessages.length === 0 ? (
<div className="flex min-h-32 items-center justify-center px-3 py-6 text-center text-sm leading-6 text-muted-foreground"> <div className="flex min-h-32 items-center justify-center px-3 py-6 text-center text-sm leading-6 text-muted-foreground">
{t("kefu.emptyPrompt")} {t("supportChat.emptyPrompt")}
</div> </div>
) : null} ) : null}
@@ -277,7 +277,7 @@ const MessageItem = memo(
const t = useI18n() const t = useI18n()
const { open } = useImageLightbox() const { open } = useImageLightbox()
const isCustomer = message.senderType === "customer" const isCustomer = message.senderType === "customer"
const senderName = isCustomer ? t("kefu.customerSelf") : message.senderName?.trim() || t("kefu.agentLabel") const senderName = isCustomer ? t("supportChat.customerSelf") : message.senderName?.trim() || t("supportChat.agentLabel")
const avatarSrc = const avatarSrc =
!isCustomer && message.senderAvatar?.trim() ? message.senderAvatar.trim() : undefined !isCustomer && message.senderAvatar?.trim() ? message.senderAvatar.trim() : undefined
const htmlContent = renderIMMessageHTML(message) const htmlContent = renderIMMessageHTML(message)
@@ -301,7 +301,7 @@ const MessageItem = memo(
<Avatar className="mt-5"> <Avatar className="mt-5">
{avatarSrc ? <AvatarImage src={avatarSrc} alt="" /> : null} {avatarSrc ? <AvatarImage src={avatarSrc} alt="" /> : null}
<AvatarFallback className="bg-muted text-muted-foreground"> <AvatarFallback className="bg-muted text-muted-foreground">
{fallbackName || t("kefu.customerFallback")} {fallbackName || t("supportChat.customerFallback")}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
) : null} ) : null}
@@ -316,7 +316,7 @@ const MessageItem = memo(
<span className="font-medium">{senderName}</span> <span className="font-medium">{senderName}</span>
<span>{formatDateTime(message.sentAt)}</span> <span>{formatDateTime(message.sentAt)}</span>
{isCustomer ? ( {isCustomer ? (
<span>{message.agentRead ? t("kefu.agentRead") : t("kefu.agentUnread")}</span> <span>{message.agentRead ? t("supportChat.agentRead") : t("supportChat.agentUnread")}</span>
) : null} ) : null}
</div> </div>
<div <div
@@ -115,7 +115,7 @@ async function signUserToken(config: WidgetDemoConfig, t: (key: string) => strin
.sign(new TextEncoder().encode(secret)) .sign(new TextEncoder().encode(secret))
} }
export function KefuWidgetDemo() { export function SupportWidgetDemo() {
const t = useI18n() const t = useI18n()
const [config, setConfig] = useState<WidgetDemoConfig>({ const [config, setConfig] = useState<WidgetDemoConfig>({
...INITIAL_CONFIG, ...INITIAL_CONFIG,
@@ -193,7 +193,7 @@ export function KefuWidgetDemo() {
const configLines = [` channelId: "${config.channelId || ""}"`] const configLines = [` channelId: "${config.channelId || ""}"`]
if (config.authMode === "jwt") { if (config.authMode === "jwt") {
configLines.push(` async getUserToken() { configLines.push(` async getUserToken() {
const res = await fetch("/api/kefu/user-token", { credentials: "include" }); const res = await fetch("/api/support/user-token", { credentials: "include" });
const data = await res.json(); const data = await res.json();
return data.userToken; return data.userToken;
}`) }`)
+2 -2
View File
@@ -1,6 +1,6 @@
import { request } from "@/lib/api/client" import { request } from "@/lib/api/client"
import { translateCurrentMessage } from "@/i18n/messages" import { translateCurrentMessage } from "@/i18n/messages"
import { readKefuChatRuntimeConfig } from "@/lib/sdk/runtime-config" import { readSupportChatRuntimeConfig } from "@/lib/sdk/runtime-config"
import { generateUUID } from "@/lib/utils" import { generateUUID } from "@/lib/utils"
export type Paging = { export type Paging = {
@@ -159,7 +159,7 @@ export function getGuestId() {
} }
function getRuntimeImConfig() { function getRuntimeImConfig() {
const widgetConfig = readKefuChatRuntimeConfig() const widgetConfig = readSupportChatRuntimeConfig()
const baseUrl = (widgetConfig.apiBaseUrl || widgetConfig.baseUrl || API_BASE_URL) const baseUrl = (widgetConfig.apiBaseUrl || widgetConfig.baseUrl || API_BASE_URL)
.trim() .trim()
.replace(/\/$/, "") .replace(/\/$/, "")
+9 -9
View File
@@ -51,12 +51,12 @@ export function renderIMMessageHTML(message: {
asset.filename || "image" asset.filename || "image"
)}"></p>` )}"></p>`
} }
return `<p>${escapeHTML(t("kefu.imageSummary"))}</p>` return `<p>${escapeHTML(t("supportChat.imageSummary"))}</p>`
} }
if (message.messageType === "attachment") { if (message.messageType === "attachment") {
if (asset?.url) { if (asset?.url) {
const title = escapeHTML(asset.filename || message.content || t("kefu.attachmentSummary")) const title = escapeHTML(asset.filename || message.content || t("supportChat.attachmentSummary"))
const meta = formatFileSize(asset.fileSize ?? 0) const meta = formatFileSize(asset.fileSize ?? 0)
const metaHTML = meta ? `<div class="im-attachment-meta">${escapeHTML(meta)}</div>` : "" const metaHTML = meta ? `<div class="im-attachment-meta">${escapeHTML(meta)}</div>` : ""
return `<div class="im-attachment"><a href="${escapeHTMLAttr( return `<div class="im-attachment"><a href="${escapeHTMLAttr(
@@ -65,7 +65,7 @@ export function renderIMMessageHTML(message: {
asset.filename || "" asset.filename || ""
)}" class="im-attachment-link"><span class="im-attachment-icon" aria-hidden="true">${getAttachmentIconSVG()}</span><span class="im-attachment-content"><span class="im-attachment-title">${title}</span>${metaHTML}</span></a></div>` )}" class="im-attachment-link"><span class="im-attachment-icon" aria-hidden="true">${getAttachmentIconSVG()}</span><span class="im-attachment-content"><span class="im-attachment-title">${title}</span>${metaHTML}</span></a></div>`
} }
return `<p>${escapeHTML(message.content || t("kefu.attachmentSummary"))}</p>` return `<p>${escapeHTML(message.content || t("supportChat.attachmentSummary"))}</p>`
} }
return renderTextMessageHTML(message.content || "") return renderTextMessageHTML(message.content || "")
@@ -77,13 +77,13 @@ export function summarizeIMMessage(message: {
payload?: string payload?: string
}) { }) {
if (message.messageType === "image") { if (message.messageType === "image") {
return t("kefu.imageSummary") return t("supportChat.imageSummary")
} }
if (message.messageType === "attachment") { if (message.messageType === "attachment") {
const asset = parseMessageAssetPayload(message.payload) const asset = parseMessageAssetPayload(message.payload)
return asset?.filename?.trim() return asset?.filename?.trim()
? `${t("kefu.attachmentSummary")} ${asset.filename.trim()}` ? `${t("supportChat.attachmentSummary")} ${asset.filename.trim()}`
: t("kefu.attachmentSummary") : t("supportChat.attachmentSummary")
} }
if (message.messageType === "html") { if (message.messageType === "html") {
const text = extractTextFromHTML(message.content) const text = extractTextFromHTML(message.content)
@@ -91,11 +91,11 @@ export function summarizeIMMessage(message: {
return text.substring(0, 100) return text.substring(0, 100)
} }
if (message.content.includes("<img")) { if (message.content.includes("<img")) {
return t("kefu.imageSummary") return t("supportChat.imageSummary")
} }
return t("kefu.messageSummary") return t("supportChat.messageSummary")
} }
return message.content?.substring(0, 100) || t("kefu.messageSummary") return message.content?.substring(0, 100) || t("supportChat.messageSummary")
} }
export function formatFileSize(size: number) { export function formatFileSize(size: number) {
+2 -2
View File
@@ -1,6 +1,6 @@
import { createWebSocketBaseUrl } from "@/lib/api/websocket" import { createWebSocketBaseUrl } from "@/lib/api/websocket"
import { getCustomerSessionToken, type ImMessage } from "@/lib/api/im" import { getCustomerSessionToken, type ImMessage } from "@/lib/api/im"
import { readKefuChatRuntimeConfig } from "@/lib/sdk/runtime-config" import { readSupportChatRuntimeConfig } from "@/lib/sdk/runtime-config"
import type { import type {
RealtimeConversationPatch, RealtimeConversationPatch,
RealtimeMessageCreatedPayload, RealtimeMessageCreatedPayload,
@@ -22,7 +22,7 @@ export type ImRealtimeEnvelope = {
} }
export function createImRealtimeConnection() { export function createImRealtimeConnection() {
const config = readKefuChatRuntimeConfig() const config = readSupportChatRuntimeConfig()
const apiBaseUrl = (config.apiBaseUrl || "").trim() const apiBaseUrl = (config.apiBaseUrl || "").trim()
const baseUrl = apiBaseUrl const baseUrl = apiBaseUrl
? apiBaseUrl.replace(/^http/, "ws").replace(/\/$/, "") ? apiBaseUrl.replace(/^http/, "ws").replace(/\/$/, "")
+2 -2
View File
@@ -16,7 +16,7 @@ export type CSAgentConfig = {
width?: string width?: string
} }
export type KefuChatRuntimeConfig = Omit<CSAgentConfig, "getUserToken"> & { export type SupportChatRuntimeConfig = Omit<CSAgentConfig, "getUserToken"> & {
/** Used only by /support/chat to exchange for a chat token; not part of CSAgentConfig. */ /** Used only by /support/chat to exchange for a chat token; not part of CSAgentConfig. */
userToken?: string userToken?: string
} }
@@ -33,7 +33,7 @@ declare global {
interface Window { interface Window {
CSAgentConfig?: CSAgentConfig CSAgentConfig?: CSAgentConfig
CSAgentWidget?: CSAgentWidget CSAgentWidget?: CSAgentWidget
__CS_AGENT_WIDGET_CONFIG__?: KefuChatRuntimeConfig __CS_AGENT_WIDGET_CONFIG__?: SupportChatRuntimeConfig
__CS_AGENT_WIDGET_STATE__?: unknown __CS_AGENT_WIDGET_STATE__?: unknown
} }
} }
+4 -4
View File
@@ -1,7 +1,7 @@
import type { import type {
CSAgentConfig, CSAgentConfig,
CSAgentWidget, CSAgentWidget,
KefuChatRuntimeConfig, SupportChatRuntimeConfig,
} from "./config-types" } from "./config-types"
type NormalizedCSAgentConfig = CSAgentConfig & { type NormalizedCSAgentConfig = CSAgentConfig & {
@@ -24,7 +24,7 @@ type WidgetState = {
frameHideTimer: number | null frameHideTimer: number | null
frameDestroyTimer: number | null frameDestroyTimer: number | null
config: NormalizedCSAgentConfig | null config: NormalizedCSAgentConfig | null
frameConfig: KefuChatRuntimeConfig | null frameConfig: SupportChatRuntimeConfig | null
frameUrl: URL | null frameUrl: URL | null
animationDuration: number animationDuration: number
listenerBound?: boolean listenerBound?: boolean
@@ -57,7 +57,7 @@ function getLauncherText() {
} }
type FrameMessage = type FrameMessage =
| { type: "cs-agent:init"; payload: KefuChatRuntimeConfig } | { type: "cs-agent:init"; payload: SupportChatRuntimeConfig }
| { type: "cs-agent:open" } | { type: "cs-agent:open" }
| { type: "cs-agent:minimize" } | { type: "cs-agent:minimize" }
| { type: "cs-agent:maximized"; payload: { isMaximized: boolean } } | { type: "cs-agent:maximized"; payload: { isMaximized: boolean } }
@@ -135,7 +135,7 @@ type FrameMessage =
function createFrameConfig( function createFrameConfig(
config: NormalizedCSAgentConfig, config: NormalizedCSAgentConfig,
userToken: string userToken: string
): KefuChatRuntimeConfig { ): SupportChatRuntimeConfig {
const { getUserToken: _getUserToken, ...payload } = config const { getUserToken: _getUserToken, ...payload } = config
if (userToken) { if (userToken) {
return { ...payload, userToken } return { ...payload, userToken }
+4 -4
View File
@@ -1,6 +1,6 @@
import type { KefuChatRuntimeConfig } from "@/lib/sdk/config-types" import type { SupportChatRuntimeConfig } from "@/lib/sdk/config-types"
export function readKefuChatRuntimeConfig(): KefuChatRuntimeConfig { export function readSupportChatRuntimeConfig(): SupportChatRuntimeConfig {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return { return {
channelId: "", channelId: "",
@@ -10,7 +10,7 @@ export function readKefuChatRuntimeConfig(): KefuChatRuntimeConfig {
} }
const query = new URLSearchParams(window.location.search) const query = new URLSearchParams(window.location.search)
const fallback: KefuChatRuntimeConfig = { const fallback: SupportChatRuntimeConfig = {
channelId: channelId:
query.get("channelId") ?? query.get("channelId") ??
process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() ?? process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() ??
@@ -46,7 +46,7 @@ export function readKefuChatRuntimeConfig(): KefuChatRuntimeConfig {
return fallback return fallback
} }
export function setKefuChatRuntimeConfig(config: KefuChatRuntimeConfig) { export function setSupportChatRuntimeConfig(config: SupportChatRuntimeConfig) {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return return
} }
@@ -38,8 +38,8 @@ import { summarizeIMMessage } from "@/lib/im-message"
import { createRealtimeConnectionManager } from "@/lib/realtime-connection" import { createRealtimeConnectionManager } from "@/lib/realtime-connection"
import { generateUUID } from "@/lib/utils" import { generateUUID } from "@/lib/utils"
import { import {
readKefuChatRuntimeConfig, readSupportChatRuntimeConfig,
setKefuChatRuntimeConfig, setSupportChatRuntimeConfig,
} from "@/lib/sdk/runtime-config" } from "@/lib/sdk/runtime-config"
import { translateCurrentMessage } from "@/i18n/messages" import { translateCurrentMessage } from "@/i18n/messages"
@@ -107,7 +107,7 @@ function markConversationReadMessages(
return next return next
} }
export type KefuChatStore = { export type SupportChatStore = {
title: string title: string
subtitle: string subtitle: string
themeColor: string themeColor: string
@@ -149,7 +149,7 @@ function t(key: string) {
return translateCurrentMessage(key) return translateCurrentMessage(key)
} }
export const useKefuChatStore = create<KefuChatStore>((set, get) => { export const useSupportChatStore = create<SupportChatStore>((set, get) => {
const realtime = createRealtimeConnectionManager({ const realtime = createRealtimeConnectionManager({
createSocket: createImRealtimeConnection, createSocket: createImRealtimeConnection,
canReconnect: () => Boolean(get().isOpen && get().conversation?.id), canReconnect: () => Boolean(get().isOpen && get().conversation?.id),
@@ -204,7 +204,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
document.visibilityState !== "visible" document.visibilityState !== "visible"
) { ) {
const state = get() const state = get()
showNotification(t("kefu.newMessage"), getNotificationBody(message), () => { showNotification(t("supportChat.newMessage"), getNotificationBody(message), () => {
state.setIsOpen(true) state.setIsOpen(true)
state.setIsVisible(true) state.setIsVisible(true)
}) })
@@ -236,7 +236,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
} }
return { return {
title: t("kefu.title"), title: t("supportChat.title"),
subtitle: "", subtitle: "",
themeColor: "#2563eb", themeColor: "#2563eb",
conversation: null, conversation: null,
@@ -285,15 +285,15 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
} }
if (widgetConfig.channelId) { if (widgetConfig.channelId) {
setKefuChatRuntimeConfig({ setSupportChatRuntimeConfig({
...readKefuChatRuntimeConfig(), ...readSupportChatRuntimeConfig(),
channelId: channelId:
widgetConfig.channelId || readKefuChatRuntimeConfig().channelId, widgetConfig.channelId || readSupportChatRuntimeConfig().channelId,
}) })
} }
set({ set({
title: widgetConfig.title || t("kefu.title"), title: widgetConfig.title || t("supportChat.title"),
subtitle: widgetConfig.subtitle || "", subtitle: widgetConfig.subtitle || "",
themeColor: widgetConfig.themeColor || "#2563eb", themeColor: widgetConfig.themeColor || "#2563eb",
}) })
@@ -324,7 +324,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
} }
set({ set({
status: "disconnected", status: "disconnected",
error: error instanceof Error ? error.message : t("kefu.initFailed"), error: error instanceof Error ? error.message : t("supportChat.initFailed"),
}) })
} }
} }
@@ -355,7 +355,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
}) })
} catch (error) { } catch (error) {
set({ set({
error: error instanceof Error ? error.message : t("kefu.loadMessagesFailed"), error: error instanceof Error ? error.message : t("supportChat.loadMessagesFailed"),
}) })
throw error throw error
} }
@@ -391,7 +391,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
}) })
} catch (error) { } catch (error) {
set({ set({
error: error instanceof Error ? error.message : t("kefu.syncMessagesFailed"), error: error instanceof Error ? error.message : t("supportChat.syncMessagesFailed"),
}) })
} }
}, },
@@ -434,7 +434,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
} catch (error) { } catch (error) {
set({ set({
messagesLoadingMore: false, messagesLoadingMore: false,
error: error instanceof Error ? error.message : t("kefu.loadHistoryFailed"), error: error instanceof Error ? error.message : t("supportChat.loadHistoryFailed"),
}) })
throw error throw error
} }
@@ -496,7 +496,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
conversationId, conversationId,
messageType: "html", messageType: "html",
content, content,
clientMsgId: `kefu_html_${generateUUID()}`, clientMsgId: `support_chat_html_${generateUUID()}`,
}) })
set((state) => ({ set((state) => ({
sending: false, sending: false,
@@ -519,7 +519,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
} catch (error) { } catch (error) {
set({ set({
sending: false, sending: false,
error: error instanceof Error ? error.message : t("kefu.sendMessageFailed"), error: error instanceof Error ? error.message : t("supportChat.sendMessageFailed"),
}) })
throw error throw error
} }
@@ -540,7 +540,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
return await uploadImImage(conversationId, file) return await uploadImImage(conversationId, file)
} catch (error) { } catch (error) {
set({ set({
error: error instanceof Error ? error.message : t("kefu.uploadImageFailed"), error: error instanceof Error ? error.message : t("supportChat.uploadImageFailed"),
}) })
return null return null
} finally { } finally {
@@ -562,7 +562,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
messageType: "attachment", messageType: "attachment",
content: asset.filename, content: asset.filename,
payload: JSON.stringify({ assetId: asset.assetId }), payload: JSON.stringify({ assetId: asset.assetId }),
clientMsgId: `kefu_attachment_${generateUUID()}`, clientMsgId: `support_chat_attachment_${generateUUID()}`,
}) })
set((state) => ({ set((state) => ({
uploadingAsset: false, uploadingAsset: false,
@@ -585,7 +585,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
} catch (error) { } catch (error) {
set({ set({
uploadingAsset: false, uploadingAsset: false,
error: error instanceof Error ? error.message : t("kefu.sendAttachmentFailed"), error: error instanceof Error ? error.message : t("supportChat.sendAttachmentFailed"),
}) })
throw error throw error
} }
@@ -614,7 +614,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
} catch (error) { } catch (error) {
set({ set({
closingConversation: false, closingConversation: false,
error: error instanceof Error ? error.message : t("kefu.closeConversationFailed"), error: error instanceof Error ? error.message : t("supportChat.closeConversationFailed"),
}) })
throw error throw error
} }
@@ -634,7 +634,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
} catch (error) { } catch (error) {
set({ set({
status: "disconnected", status: "disconnected",
error: error instanceof Error ? error.message : t("kefu.refreshFailed"), error: error instanceof Error ? error.message : t("supportChat.refreshFailed"),
}) })
} }
}, },
@@ -1,5 +1,5 @@
import { setKefuChatRuntimeConfig } from "@/lib/sdk/runtime-config" import { setSupportChatRuntimeConfig } from "@/lib/sdk/runtime-config"
import type { KefuChatRuntimeConfig } from "@/lib/sdk/config-types" import type { SupportChatRuntimeConfig } from "@/lib/sdk/config-types"
type HostBridgeOptions = { type HostBridgeOptions = {
onInit?: () => void onInit?: () => void
@@ -17,7 +17,7 @@ const REQUEST_MINIMIZE_MESSAGE_TYPE = "cs-agent:request-minimize"
const REQUEST_CLOSE_MESSAGE_TYPE = "cs-agent:request-close" const REQUEST_CLOSE_MESSAGE_TYPE = "cs-agent:request-close"
const REQUEST_TOGGLE_MAXIMIZE_MESSAGE_TYPE = "cs-agent:request-toggle-maximize" const REQUEST_TOGGLE_MAXIMIZE_MESSAGE_TYPE = "cs-agent:request-toggle-maximize"
export function bindKefuHostBridge(options: HostBridgeOptions = {}) { export function bindSupportHostBridge(options: HostBridgeOptions = {}) {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return () => undefined return () => undefined
} }
@@ -30,7 +30,7 @@ export function bindKefuHostBridge(options: HostBridgeOptions = {}) {
const data = event.data as const data = event.data as
| { | {
type?: string type?: string
payload?: KefuChatRuntimeConfig | { isMaximized?: boolean } payload?: SupportChatRuntimeConfig | { isMaximized?: boolean }
} }
| undefined | undefined
if (!data?.type) { if (!data?.type) {
@@ -38,7 +38,7 @@ export function bindKefuHostBridge(options: HostBridgeOptions = {}) {
} }
if (data.type === INIT_MESSAGE_TYPE && data.payload) { if (data.type === INIT_MESSAGE_TYPE && data.payload) {
setKefuChatRuntimeConfig(data.payload as KefuChatRuntimeConfig) setSupportChatRuntimeConfig(data.payload as SupportChatRuntimeConfig)
options.onInit?.() options.onInit?.()
return return
} }
@@ -72,14 +72,14 @@ function postToParent(type: string) {
} }
} }
export function requestKefuHostMinimize() { export function requestSupportHostMinimize() {
postToParent(REQUEST_MINIMIZE_MESSAGE_TYPE) postToParent(REQUEST_MINIMIZE_MESSAGE_TYPE)
} }
export function requestKefuHostClose() { export function requestSupportHostClose() {
postToParent(REQUEST_CLOSE_MESSAGE_TYPE) postToParent(REQUEST_CLOSE_MESSAGE_TYPE)
} }
export function requestKefuHostToggleMaximize() { export function requestSupportHostToggleMaximize() {
postToParent(REQUEST_TOGGLE_MAXIMIZE_MESSAGE_TYPE) postToParent(REQUEST_TOGGLE_MAXIMIZE_MESSAGE_TYPE)
} }
+1 -1
View File
@@ -270,7 +270,7 @@
"claiming": "Claiming...", "claiming": "Claiming...",
"confirmClaim": "Claim" "confirmClaim": "Claim"
}, },
"kefu": { "supportChat": {
"title": "Support", "title": "Support",
"agentLabel": "Support", "agentLabel": "Support",
"customerSelf": "You", "customerSelf": "You",
+1 -1
View File
@@ -270,7 +270,7 @@
"claiming": "认领中...", "claiming": "认领中...",
"confirmClaim": "确认认领" "confirmClaim": "确认认领"
}, },
"kefu": { "supportChat": {
"title": "在线客服", "title": "在线客服",
"agentLabel": "客服", "agentLabel": "客服",
"customerSelf": "我", "customerSelf": "我",