chore: complete clean Agent Desk source snapshot

This commit is contained in:
2026-10-16 12:03:16 +00:00
parent 756ae33d05
commit e57610a27c
123 changed files with 37513 additions and 0 deletions

View File

@@ -0,0 +1,280 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { Menubar as MenubarPrimitive } from "@base-ui/react/menubar"
import { cn } from "@/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { CheckIcon } from "lucide-react"
function Menubar({ className, ...props }: MenubarPrimitive.Props) {
return (
<MenubarPrimitive
data-slot="menubar"
className={cn(
"flex h-8 items-center gap-0.5 rounded-lg border p-[3px]",
className
)}
{...props}
/>
)
}
function MenubarMenu({ ...props }: React.ComponentProps<typeof DropdownMenu>) {
return <DropdownMenu data-slot="menubar-menu" {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof DropdownMenuGroup>) {
return <DropdownMenuGroup data-slot="menubar-group" {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPortal>) {
return <DropdownMenuPortal data-slot="menubar-portal" {...props} />
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof DropdownMenuTrigger>) {
return (
<DropdownMenuTrigger
data-slot="menubar-trigger"
className={cn(
"flex items-center rounded-sm px-1.5 py-[2px] text-sm font-medium outline-hidden select-none hover:bg-muted aria-expanded:bg-muted",
className
)}
{...props}
/>
)
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn("min-w-36 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", className )}
{...props}
/>
)
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuItem>) {
return (
<DropdownMenuItem
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/menubar-item gap-1.5 rounded-md px-1.5 py-1 text-sm focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function MenubarCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuRadioGroup>) {
return <DropdownMenuRadioGroup data-slot="menubar-radio-group" {...props} />
}
function MenubarRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="menubar-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuLabel> & {
inset?: boolean
}) {
return (
<DropdownMenuLabel
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-sm font-medium data-inset:pl-7",
className
)}
{...props}
/>
)
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuSeparator>) {
return (
<DropdownMenuSeparator
data-slot="menubar-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<typeof DropdownMenuShortcut>) {
return (
<DropdownMenuShortcut
data-slot="menubar-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/menubar-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function MenubarSub({
...props
}: React.ComponentProps<typeof DropdownMenuSub>) {
return <DropdownMenuSub data-slot="menubar-sub" {...props} />
}
function MenubarSubTrigger({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuSubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuSubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"gap-1.5 rounded-md px-1.5 py-1 text-sm focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuSubContent>) {
return (
<DropdownMenuSubContent
data-slot="menubar-sub-content"
className={cn("min-w-32 rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}

View File

@@ -0,0 +1,131 @@
"use client"
import * as React from "react"
import {
MessageScroller as MessageScrollerPrimitive,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from "@shadcn/react/message-scroller"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ArrowDownIcon } from "lucide-react"
function MessageScrollerProvider(
props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>
) {
return <MessageScrollerPrimitive.Provider {...props} />
}
function MessageScroller({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Root>) {
return (
<MessageScrollerPrimitive.Root
data-slot="message-scroller"
className={cn(
"group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden",
className
)}
{...props}
/>
)
}
function MessageScrollerViewport({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Viewport>) {
return (
<MessageScrollerPrimitive.Viewport
data-slot="message-scroller-viewport"
className={cn(
"size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent",
className
)}
{...props}
/>
)
}
function MessageScrollerContent({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Content>) {
return (
<MessageScrollerPrimitive.Content
data-slot="message-scroller-content"
className={cn("flex h-max min-h-full flex-col gap-6", className)}
{...props}
/>
)
}
function MessageScrollerItem({
className,
scrollAnchor = false,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Item>) {
return (
<MessageScrollerPrimitive.Item
data-slot="message-scroller-item"
scrollAnchor={scrollAnchor}
className={cn(
"min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]",
className
)}
{...props}
/>
)
}
function MessageScrollerButton({
direction = "end",
className,
children,
render,
variant = "secondary",
size = "icon-sm",
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Button> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<MessageScrollerPrimitive.Button
data-slot="message-scroller-button"
data-direction={direction}
data-variant={variant}
data-size={size}
direction={direction}
className={cn(
"absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180",
className
)}
render={render ?? <Button variant={variant} size={size} />}
{...props}
>
{children ?? (
<>
<ArrowDownIcon
/>
<span className="sr-only">
{direction === "end" ? "Scroll to end" : "Scroll to start"}
</span>
</>
)}
</MessageScrollerPrimitive.Button>
)
}
export {
MessageScrollerProvider,
MessageScroller,
MessageScrollerViewport,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerButton,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
}

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function MessageGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
)
}
function Message({
className,
align = "start",
...props
}: React.ComponentProps<"div"> & { align?: "start" | "end" }) {
return (
<div
data-slot="message"
data-align={align}
className={cn(
"group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse",
className
)}
{...props}
/>
)
}
function MessageAvatar({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-avatar"
className={cn(
"flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8",
className
)}
{...props}
/>
)
}
function MessageContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-content"
className={cn(
"flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end",
className
)}
{...props}
/>
)
}
function MessageHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-header"
className={cn(
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0",
className
)}
{...props}
/>
)
}
function MessageFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-footer"
className={cn(
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end",
className
)}
{...props}
/>
)
}
export {
MessageGroup,
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageHeader,
}

View File

@@ -0,0 +1,90 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}

View File

@@ -0,0 +1,50 @@
"use client"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: ResizablePrimitive.GroupProps) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full aria-[orientation=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border" />
)}
</ResizablePrimitive.Separator>
)
}
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }

View File

@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

135
web/components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-base font-medium text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,744 @@
"use client"
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-label",
sidebar: "group-label",
},
})
}
function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-action",
sidebar: "group-action",
},
})
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-primary data-active:font-medium data-active:text-sidebar-primary-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
render,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar()
const comp = useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
...(isActive ? { "data-active": "" } : {}),
} as React.ButtonHTMLAttributes<HTMLButtonElement> & Record<string, unknown>,
props
),
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: {
slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
})
if (!tooltip) {
return comp
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
{comp}
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
render,
showOnHover = false,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean
}) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-action",
sidebar: "menu-action",
},
})
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
render,
size = "md",
isActive = false,
tooltip,
className,
...props
}: useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md"
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
}) {
const { isMobile } = useSidebar()
const comp = useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-primary data-active:text-sidebar-primary-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className
),
},
props
),
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: {
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size,
active: isActive,
},
})
if (!tooltip) {
return comp
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
{comp}
<TooltipContent side="right" align="center" hidden={isMobile} {...tooltip} />
</Tooltip>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,49 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,32 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

116
web/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,82 @@
"use client"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 0,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</TogglePrimitive>
)
}
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,45 @@
"use client"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

View File

@@ -0,0 +1,66 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,142 @@
"use client"
import { BellIcon, KeyRoundIcon, LogOutIcon, UserIcon } from "lucide-react"
import { useRouter } from "next/navigation"
import { useState } from "react"
import { ChangePasswordDialog } from "@/components/change-password-dialog"
import { OperatorReadinessIndicator } from "@/components/operator-readiness-indicator"
import { PaletteToggle } from "@/components/palette-toggle"
import { RealtimeConnectionStatus } from "@/components/realtime-connection-status"
import { ThemeToggle } from "@/components/theme-toggle"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useAuth } from "@/components/auth-provider"
import { useNotifications } from "@/components/notification-provider"
import { useI18n } from "@/i18n/provider"
import { useAgentConversationsStore } from "@/lib/stores/agent-conversations"
export function WorkbenchHeader() {
const realtimeStatus = useAgentConversationsStore((state) => state.realtimeStatus)
return (
<header className="flex h-(--header-height) shrink-0 items-center border-b border-border/70 bg-background/88 backdrop-blur supports-[backdrop-filter]:bg-background/76">
<div className="flex w-full min-w-0 items-center justify-end gap-3 px-3 lg:px-4">
<div className="flex min-w-0 items-center justify-end gap-2">
<OperatorReadinessIndicator />
<div className="hidden sm:block">
<RealtimeConnectionStatus status={realtimeStatus} compact />
</div>
<PaletteToggle />
<ThemeToggle />
<WorkbenchUserMenu />
</div>
</div>
</header>
)
}
function WorkbenchUserMenu() {
const t = useI18n()
const router = useRouter()
const { session, signOut } = useAuth()
const { unreadCount } = useNotifications()
const [changePasswordOpen, setChangePasswordOpen] = useState(false)
const user = {
name: session?.user.nickname || session?.user.username || t("common.notSignedIn"),
email: session?.user.username || t("common.guest"),
avatar: session?.user.avatar || "",
}
const fallback = user.name.slice(0, 1).toUpperCase() || "U"
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon"
className="relative size-9 rounded-full"
aria-label={user.name}
/>
}
>
<Avatar className="size-8">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback>
{fallback || <UserIcon className="size-4" />}
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-56" align="end" sideOffset={8}>
<DropdownMenuGroup>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="size-8">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback>{fallback}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs text-muted-foreground">
{user.email}
</span>
</div>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => {
router.push("/dashboard/notifications")
}}
className="gap-2"
>
<BellIcon />
<span className="flex-1">{t("nav.notifications")}</span>
{unreadCount > 0 ? (
<Badge className="h-5 min-w-5 px-1.5">
{unreadCount > 99 ? "99+" : unreadCount}
</Badge>
) : null}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setChangePasswordOpen(true)
}}
>
<KeyRoundIcon />
{t("nav.changePassword")}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
void signOut()
}}
>
<LogOutIcon />
{t("nav.signOut")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ChangePasswordDialog
open={changePasswordOpen}
onOpenChange={setChangePasswordOpen}
onSuccess={signOut}
/>
</>
)
}

View File

@@ -0,0 +1,86 @@
"use client"
import { MessagesSquareIcon, TicketCheckIcon } from "lucide-react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { useI18n } from "@/i18n/provider"
import { cn } from "@/lib/utils"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { WorkspaceSwitcher } from "@/components/workspace-switcher"
const workbenchRailItems = [
{
key: "conversations",
titleKey: "nav.conversations",
href: "/workbench",
icon: MessagesSquareIcon,
},
{
key: "tickets",
titleKey: "nav.tickets",
href: "/workbench/tickets",
icon: TicketCheckIcon,
},
]
function normalizePath(path: string | null | undefined) {
if (!path) {
return ""
}
return path.length > 1 ? path.replace(/\/+$/, "") : path
}
export function WorkbenchRail() {
const t = useI18n()
const pathname = usePathname()
const currentPath = normalizePath(pathname)
return (
<aside className="flex h-svh w-16 shrink-0 flex-col items-center border-r border-border/70 bg-sidebar px-2 py-3 text-sidebar-foreground">
<div className="mb-4 flex w-full justify-center">
<WorkspaceSwitcher currentWorkspace="workbench" variant="rail" />
</div>
<nav className="flex w-full flex-col items-center gap-2">
{workbenchRailItems.map((item) => {
const Icon = item.icon
const itemPath = normalizePath(item.href)
const isActive =
item.key === "conversations"
? currentPath === "/workbench"
: currentPath === itemPath || currentPath.startsWith(`${itemPath}/`)
const title = t(item.titleKey)
return (
<Tooltip key={item.key}>
<TooltipTrigger
render={
<Link
href={item.href}
aria-label={title}
aria-current={isActive ? "page" : undefined}
className={cn(
"flex size-11 items-center justify-center rounded-lg bg-transparent text-sidebar-foreground/65 transition-colors hover:text-sidebar-foreground",
isActive &&
"bg-sidebar-primary text-sidebar-primary-foreground hover:bg-sidebar-primary hover:text-sidebar-primary-foreground"
)}
/>
}
>
<Icon className="size-4" />
<span className="sr-only">{title}</span>
</TooltipTrigger>
<TooltipContent side="right" align="center">
{title}
</TooltipContent>
</Tooltip>
)
})}
</nav>
</aside>
)
}

View File

@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { describe, it } from "node:test";
const source = await readFile(new URL("./workspace-switcher.tsx", import.meta.url), "utf8");
const appSidebarSource = await readFile(new URL("./app-sidebar.tsx", import.meta.url), "utf8");
describe("workspace switcher config", () => {
it("contains dashboard and workbench destinations", async () => {
assert.match(source, /key:\s*"dashboard"[\s\S]*href:\s*"\/dashboard"[\s\S]*labelKey:\s*"workspace\.dashboard"/);
assert.match(source, /key:\s*"workbench"[\s\S]*href:\s*"\/workbench"[\s\S]*labelKey:\s*"workspace\.workbench"/);
});
it("wraps the dropdown label in a Base UI menu group", async () => {
assert.match(source, /<DropdownMenuGroup>[\s\S]*<DropdownMenuLabel>\{t\("workspace\.switchWorkspace"\)\}<\/DropdownMenuLabel>/);
});
it("does not auto-open the rail menu from focus or hover events", async () => {
assert.doesNotMatch(source, /onFocus=\{openHoverMenu\}/);
assert.doesNotMatch(source, /onBlur=\{closeHoverMenu\}/);
assert.doesNotMatch(source, /onPointerEnter=\{openHoverMenu\}/);
assert.doesNotMatch(source, /onPointerLeave=\{closeHoverMenu\}/);
assert.doesNotMatch(source, /hoverOpen/);
});
it("centers the dashboard switcher logo and shows a collapsed switch indicator", async () => {
assert.match(source, /variant === "sidebar" &&[\s\S]*group-data-\[collapsible=icon\]:p-0!/);
assert.match(source, /variant === "sidebar" &&[\s\S]*group-data-\[collapsible=icon\]:justify-center/);
assert.match(appSidebarSource, /className="relative data-\[slot=sidebar-menu-button\]:p-1\.5! group-data-\[collapsible=icon\]:justify-center group-data-\[collapsible=icon\]:p-0!"/);
assert.match(source, /const switchIndicatorClassName =\s*"absolute bottom-0\.5 right-0\.5 size-2\.5/);
assert.match(source, /variant === "rail" \? \([\s\S]*<ChevronsUpDownIcon className=\{switchIndicatorClassName\} \/>/);
assert.match(source, /className=\{cn\(switchIndicatorClassName, "hidden group-data-\[collapsible=icon\]:block"\)\}/);
});
it("uses the same compact trigger footprint for dashboard collapsed and workbench rail switchers", async () => {
assert.match(source, /variant === "rail" &&\s*"relative size-8 rounded-md/);
assert.doesNotMatch(source, /variant === "rail" &&\s*"relative size-11/);
});
});

View File

@@ -0,0 +1,233 @@
"use client"
import { Building2Icon, CheckIcon, ChevronsUpDownIcon, LayoutDashboardIcon, WrenchIcon } from "lucide-react"
import Link from "next/link"
import { useEffect, useState, type ReactElement } from "react"
import { toast } from "sonner"
import { useI18n } from "@/i18n/provider"
import { fetchPublicConfig } from "@/lib/api/config"
import { listMyOrganizations, switchOrganization, type OrganizationItem } from "@/lib/api/organization"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export type WorkspaceKey = "dashboard" | "workbench"
export type WorkspaceOption = {
key: WorkspaceKey
href: string
labelKey: string
icon: typeof LayoutDashboardIcon
}
export const workspaceOptions: WorkspaceOption[] = [
{
key: "dashboard",
href: "/dashboard",
labelKey: "workspace.dashboard",
icon: LayoutDashboardIcon,
},
{
key: "workbench",
href: "/workbench",
labelKey: "workspace.workbench",
icon: WrenchIcon,
},
]
type WorkspaceSwitcherProps = {
currentWorkspace: WorkspaceKey
variant?: "sidebar" | "header" | "rail"
className?: string
trigger?: ReactElement
}
export function WorkspaceSwitcher({
currentWorkspace,
variant = "header",
className,
trigger,
}: WorkspaceSwitcherProps) {
const t = useI18n()
const [orgs, setOrgs] = useState<OrganizationItem[]>([])
const [activeOrgId, setActiveOrgId] = useState<number | null>(null)
const [switching, setSwitching] = useState(false)
const [logoUrl, setLogoUrl] = useState("")
useEffect(() => {
let mounted = true
void fetchPublicConfig()
.then((config) => {
if (mounted) {
setLogoUrl(config.companyLogoUrl?.trim() || "")
}
})
.catch(() => {
// Fall back to the bundled logo.
})
return () => {
mounted = false
}
}, [])
useEffect(() => {
let mounted = true
listMyOrganizations()
.then((res) => {
if (mounted && res?.organizations) {
setOrgs(res.organizations)
setActiveOrgId(res.currentOrganizationId || res.organizations[0]?.id || null)
}
})
.catch(() => {})
return () => {
mounted = false
}
}, [])
const currentOption =
workspaceOptions.find((item) => item.key === currentWorkspace) ?? workspaceOptions[0]
const currentOrg = orgs.find((o) => o.id === activeOrgId) || orgs[0]
const handleSwitchOrg = async (orgId: number) => {
if (orgId === activeOrgId || switching) return
setSwitching(true)
try {
await switchOrganization(orgId)
setActiveOrgId(orgId)
toast.success("Switched organization successfully")
window.location.reload()
} catch {
toast.error("Failed to switch organization")
} finally {
setSwitching(false)
}
}
const switchIndicatorClassName =
"absolute bottom-0.5 right-0.5 size-2.5 rounded-full bg-sidebar text-sidebar-foreground/70"
const triggerClassName = cn(
"gap-2 text-left",
variant === "header" &&
"h-9 rounded-md border border-border/70 bg-background px-2.5 shadow-xs hover:bg-muted",
variant === "sidebar" &&
"relative data-[slot=sidebar-menu-button]:p-1.5! group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:p-0! group-data-[collapsible=icon]:data-[slot=sidebar-menu-button]:p-0!",
variant === "rail" &&
"relative size-8 rounded-md border-0 bg-transparent p-0 shadow-none hover:bg-sidebar-accent",
className
)
const triggerContent =
variant === "rail" ? (
<>
<img
src={logoUrl || "/images/logo.svg"}
alt={t("app.brand")}
width="32"
height="32"
className="size-7 shrink-0 object-contain"
/>
<span className="sr-only">
{currentOrg?.name || t("app.brand")} - {t(currentOption.labelKey)}
</span>
<ChevronsUpDownIcon className={switchIndicatorClassName} />
</>
) : (
<>
<img
src={logoUrl || "/images/logo.svg"}
alt={t("app.brand")}
width="32"
height="32"
className="size-7 shrink-0 object-contain"
/>
<div className="grid min-w-0 flex-1 text-left leading-tight">
<span className="truncate text-sm font-semibold">{currentOrg?.name || t("app.brand")}</span>
<span className="truncate text-xs text-muted-foreground">
{t(currentOption.labelKey)} {currentOrg?.role ? `• ${currentOrg.role}` : ""}
</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4 shrink-0 text-muted-foreground group-data-[collapsible=icon]:hidden" />
{variant === "sidebar" ? (
<ChevronsUpDownIcon
className={cn(switchIndicatorClassName, "hidden group-data-[collapsible=icon]:block")}
/>
) : null}
</>
)
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
trigger ?? <Button variant="ghost" className={triggerClassName} />
}
>
{triggerContent}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side={variant === "sidebar" || variant === "rail" ? "right" : "bottom"}
sideOffset={8}
className="w-64 min-w-64"
>
{orgs.length > 0 ? (
<>
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-1.5 text-xs text-muted-foreground font-medium">
<Building2Icon className="size-3.5" />
Organizations / Workspaces
</DropdownMenuLabel>
{orgs.map((org) => {
const isActive = org.id === activeOrgId
return (
<DropdownMenuItem
key={org.id}
className="cursor-pointer gap-2"
onClick={() => handleSwitchOrg(org.id)}
>
<div className="flex flex-1 flex-col min-w-0">
<span className="truncate font-medium text-sm">{org.name}</span>
<span className="truncate text-xs text-muted-foreground">
{org.role || "Member"} {org.plan ? `• ${org.plan}` : ""}
</span>
</div>
{isActive ? <CheckIcon className="size-4 text-primary shrink-0" /> : null}
</DropdownMenuItem>
)
})}
</DropdownMenuGroup>
<DropdownMenuSeparator />
</>
) : null}
<DropdownMenuGroup>
<DropdownMenuLabel>{t("workspace.switchWorkspace")}</DropdownMenuLabel>
{workspaceOptions.map((item) => (
<DropdownMenuItem
key={item.key}
render={<Link href={item.href} />}
className="cursor-pointer gap-2"
>
<item.icon className="size-4 text-muted-foreground" />
<span className="flex-1 truncate">{t(item.labelKey)}</span>
{item.key === currentWorkspace ? (
<CheckIcon className="size-4 text-primary" />
) : null}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,127 @@
import { mkdirSync } from "node:fs";
import { test, expect, type Page } from "@playwright/test";
const baseUrl = process.env.E2E_BASE_URL ?? "http://localhost:3000";
const username = process.env.E2E_USERNAME ?? "admin";
const password = process.env.E2E_PASSWORD ?? "";
const reportDir =
process.env.E2E_REPORT_DIR ??
"../docs/superpowers/test-reports/2026-06-13-support-workbench";
mkdirSync(reportDir, { recursive: true });
test.use({
launchOptions: {
channel: "chrome",
},
});
async function screenshot(page: Page, name: string) {
await page.screenshot({
path: `${reportDir}/${name}.png`,
fullPage: true,
});
}
async function login(page: Page) {
await page.goto(`${baseUrl}/workbench/`);
await expect(page).toHaveURL(/\/dashboard\/login/);
await screenshot(page, "01-login-redirect");
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await screenshot(page, "02-login-filled");
const loginResponsePromise = page.waitForResponse(
(response) =>
response.url().includes("/api/auth/login") && response.status() === 200,
{ timeout: 15000 },
);
await page.locator('form button[type="submit"]').click();
const loginResponse = await loginResponsePromise;
console.log("login response", loginResponse.status(), loginResponse.url());
await page.waitForURL((url) => !url.pathname.startsWith("/dashboard/login"), {
timeout: 15000,
});
await expect
.poll(() => page.evaluate(() => Boolean(window.localStorage.getItem("agent-desk-session"))))
.toBe(true);
}
async function expectWorkbenchRealtimeOnline(page: Page) {
await expect(page.getByText(/平台实时:在线|Realtime: online/)).toBeVisible({
timeout: 15000,
});
await expect(page.getByText(/平台实时:已断开|Realtime: disconnected/)).toHaveCount(0);
}
test.describe("support workbench", () => {
test("logs in and switches between workbench and dashboard", async ({ page }) => {
const runtimeErrors: string[] = [];
page.on("pageerror", (error) => {
runtimeErrors.push(error.stack || error.message);
});
page.on("console", (message) => {
if (message.type() === "error") {
runtimeErrors.push(message.text());
}
});
await login(page);
await page.goto(`${baseUrl}/workbench/`);
await page.waitForLoadState("networkidle");
await screenshot(page, "03-workbench-initial");
await expectWorkbenchRealtimeOnline(page);
await expect(page.getByText(/客服工作台|Support Workbench/).first()).toBeVisible();
const conversationsEntry = page.getByRole("link", { name: /会话|Conversations/ }).first();
const ticketsEntry = page.getByRole("link", { name: /工单|Tickets/ }).first();
await expect(conversationsEntry).toHaveAttribute(
"href",
"/workbench/",
);
await expect(ticketsEntry).toHaveAttribute(
"href",
"/workbench/tickets/",
);
await expect(conversationsEntry).toHaveClass(/bg-sidebar-primary/);
await expect(ticketsEntry).not.toHaveClass(/bg-sidebar-primary/);
await conversationsEntry.hover();
await expect(page.getByText(/会话|Conversations/).last()).toBeVisible();
await screenshot(page, "04-workbench-rail-tooltip");
await page.getByRole("button", { name: /贝壳AGENT|Agent Desk/i }).first().hover();
await expect(page.getByRole("menuitem", { name: /管理后台|Admin Dashboard/ })).toBeVisible();
await page.waitForTimeout(300);
await screenshot(page, "05-workbench-switcher-open");
await page.getByRole("menuitem", { name: /管理后台|Admin Dashboard/ }).click();
await page.waitForURL(/\/dashboard\/?$/, { timeout: 15000 });
await page.waitForLoadState("networkidle");
await screenshot(page, "06-dashboard-after-switch");
await expect(page.getByText(/管理后台|Admin Dashboard/).first()).toBeVisible();
await page.getByRole("button", { name: /贝壳AGENT|Agent Desk/i }).first().click();
await expect(page.getByRole("menuitem", { name: /客服工作台|Support Workbench/ })).toBeVisible();
await page.waitForTimeout(300);
await screenshot(page, "07-dashboard-switcher-open");
await page.getByRole("menuitem", { name: /客服工作台|Support Workbench/ }).click();
await page.waitForURL(/\/workbench\/?$/, { timeout: 15000 });
await page.waitForLoadState("networkidle");
await screenshot(page, "08-workbench-after-return");
await expect(page.getByText(/客服工作台|Support Workbench/).first()).toBeVisible();
const returnedConversationsEntry = page.getByRole("link", { name: /会话|Conversations/ }).first();
const returnedTicketsEntry = page.getByRole("link", { name: /工单|Tickets/ }).first();
await returnedTicketsEntry.click();
await page.waitForURL(/\/workbench\/tickets\/?$/, { timeout: 15000 });
await page.waitForLoadState("networkidle");
await expect(returnedTicketsEntry).toHaveClass(/bg-sidebar-primary/);
await expect(returnedConversationsEntry).not.toHaveClass(/bg-sidebar-primary/);
await expectWorkbenchRealtimeOnline(page);
await page.mouse.move(640, 360);
await screenshot(page, "09-workbench-tickets-active");
expect(runtimeErrors).toEqual([]);
});
});

8
web/embed.go Normal file
View File

@@ -0,0 +1,8 @@
//go:build !dev
package webspa
import "embed"
//go:embed all:out
var SPA embed.FS

19
web/embed_dev.go Normal file
View File

@@ -0,0 +1,19 @@
//go:build dev
package webspa
import (
"io/fs"
"os"
)
var SPA fs.FS = getDevFS()
func getDevFS() fs.FS {
for _, dir := range []string{"web", "../web", "../../web", "../../../web"} {
if info, err := os.Stat(dir); err == nil && info.IsDir() {
return os.DirFS(dir)
}
}
return os.DirFS("web")
}

20
web/embed_test.go Normal file
View File

@@ -0,0 +1,20 @@
package webspa
import (
"io/fs"
"testing"
)
func TestEmbeddedSPAIncludesNextExportAssets(t *testing.T) {
nextStaticInfo, err := fs.Stat(SPA, "out/_next/static")
if err != nil {
t.Fatalf("embedded SPA missing Next static assets: %v", err)
}
if !nextStaticInfo.IsDir() {
t.Fatalf("out/_next/static is not a directory")
}
if _, err := fs.Stat(SPA, "out/__next._tree.txt"); err != nil {
t.Fatalf("embedded SPA missing Next RSC export data: %v", err)
}
}

18
web/eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@@ -0,0 +1,286 @@
"use client"
import { useEffect, useRef } from "react"
import { toast } from "sonner"
import { createAdminWebSocketUrl } from "@/lib/api/admin"
import { type AgentMessage } from "@/lib/api/agent"
import { shouldReloadConversationListForRealtimePatch } from "@/lib/agent-conversation-realtime"
import { readSession } from "@/lib/auth"
import {
normalizeRealtimeMessage,
type RealtimeConversationPatch,
type RealtimeMessageCreatedPayload,
} from "@/lib/im-realtime-state"
import { createRealtimeConnectionManager } from "@/lib/realtime-connection"
import { getNotificationBody, showNotification } from "@/lib/services/notification"
import { playIncomingMessageSound } from "@/lib/sound-alert"
import { useAgentConversationsStore } from "@/lib/stores/agent-conversations"
export { useAgentConversationsStore }
import { useI18n } from "@/i18n/provider"
import { type RealtimeAgentActivityPayload } from "@/lib/im-realtime"
import { type OperatorPresencePayload } from "@/lib/operator-presence-state"
type AgentRealtimeConnection = ReturnType<typeof createRealtimeConnectionManager>
type AgentRealtimeEnvelope = {
eventId?: string
type?: string
data?: RealtimeMessageCreatedPayload<AgentMessage> &
RealtimeConversationPatch &
RealtimeAgentActivityPayload &
OperatorPresencePayload & {
messageId?: number
recalledAt?: string
sendStatus?: number
}
}
let activeRealtimeSocket: (() => WebSocket | null) | null = null
export function sendOperatorPresence(
conversationId: number,
action: "viewing" | "typing" | "left"
) {
const socket = activeRealtimeSocket?.()
if (socket && socket.readyState === WebSocket.OPEN && conversationId > 0) {
socket.send(JSON.stringify({ type: "presence", conversationId, action }))
}
}
export function useAgentConversationRealtime() {
const t = useI18n()
const selectedConversationId = useAgentConversationsStore(
(state) => state.selectedConversationId
)
const setRealtimeStatus = useAgentConversationsStore(
(state) => state.setRealtimeStatus
)
const realtimeRef = useRef<AgentRealtimeConnection | null>(null)
const subscribedConversationIdRef = useRef<number | null>(null)
const selectedConversationIdRef = useRef<number | null>(selectedConversationId)
const currentUserIdRef = useRef<number>(readSession()?.user.id ?? 0)
useEffect(() => {
selectedConversationIdRef.current = selectedConversationId
}, [selectedConversationId])
useEffect(() => {
const realtime = createRealtimeConnectionManager({
createSocket: () => new WebSocket(createAdminWebSocketUrl()),
onStatusChange: setRealtimeStatus,
onOpen: (socket) => {
console.info("[agent-realtime] websocket connected", {
url: socket.url,
})
const conversationId = selectedConversationIdRef.current
if (conversationId) {
socket.send(
JSON.stringify({
type: "subscribe",
topics: [`conversation:${conversationId}`],
})
)
socket.send(
JSON.stringify({
type: "presence",
conversationId,
action: "viewing",
})
)
subscribedConversationIdRef.current = conversationId
} else {
subscribedConversationIdRef.current = null
}
},
onMessage: (event, socket) => {
try {
const envelope = JSON.parse(event.data) as AgentRealtimeEnvelope
const eventType = envelope.type ?? ""
const payload = envelope.data
const conversationId = payload?.conversationId ?? 0
const eventId = envelope.eventId?.trim() ?? ""
if (
eventType === "" ||
eventType === "connected" ||
eventType === "pong" ||
eventType === "subscribed" ||
eventType === "unsubscribed"
) {
return
}
if (eventId && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: "ack", eventId }))
}
const store = useAgentConversationsStore.getState()
if (eventType === "resyncRequired") {
void store.resyncRealtimeData(conversationId).catch((error) => {
toast.error(error instanceof Error ? error.message : t("conversation.syncConversationDataFailed"))
})
return
}
if (eventType === "agent.activity" && payload) {
store.applyRealtimeAgentActivity(payload)
return
}
if (eventType === "operator.presence" && payload) {
store.applyRealtimeOperatorPresence(payload, currentUserIdRef.current)
return
}
if (eventType === "message.created") {
const message = normalizeRealtimeMessage<AgentMessage>(payload)
if (!message) {
void store.resyncRealtimeData(conversationId).catch((error) => {
toast.error(error instanceof Error ? error.message : t("conversation.syncMessagesFailed"))
})
return
}
store.applyRealtimeMessageCreated(message)
const isCustomerMsg = message.senderType === "customer"
const isAssignedToMe = (payload?.currentAssigneeId ?? 0) === currentUserIdRef.current
const isUnassigned = (payload?.currentAssigneeId ?? 0) === 0
const isCurrentActive = message.conversationId === selectedConversationIdRef.current
if (isCustomerMsg && (isAssignedToMe || isUnassigned || isCurrentActive)) {
playIncomingMessageSound()
if (typeof document !== "undefined" && document.visibilityState !== "visible") {
showNotification(t("conversation.newMessage"), getNotificationBody(message), () => {
void store.selectConversation(message.conversationId)
})
}
}
return
}
if (eventType === "message.recalled" && payload?.messageId) {
store.applyRealtimeMessageRecalled(payload.messageId, {
sendStatus: payload.sendStatus,
recalledAt: payload.recalledAt,
})
return
}
if (eventType.startsWith("conversation.") && payload) {
store.applyRealtimeConversationChanged(payload)
if (shouldReloadConversationListForRealtimePatch(payload)) {
void store.resyncRealtimeData(conversationId).catch((error) => {
toast.error(error instanceof Error ? error.message : t("conversation.syncConversationListFailed"))
})
}
}
} catch {
// ignore invalid ws payload
}
},
onClose: (event, socket) => {
console.log("[agent-realtime] websocket closed", {
url: socket.url,
readyState: socket.readyState,
code: event.code,
reason: event.reason,
wasClean: event.wasClean,
})
subscribedConversationIdRef.current = null
},
onError: (_event, socket) => {
console.log("[agent-realtime] websocket error", {
url: socket.url,
readyState: socket.readyState,
})
},
onConnectError: (error) => {
toast.error(error instanceof Error ? error.message : t("conversation.realtimeConnectFailed"))
},
})
realtimeRef.current = realtime
activeRealtimeSocket = () => realtimeRef.current?.getSocket() ?? null
realtime.connect()
return () => {
activeRealtimeSocket = null
const socket = realtimeRef.current?.getSocket()
if (
socket &&
socket.readyState === WebSocket.OPEN &&
subscribedConversationIdRef.current
) {
socket.send(
JSON.stringify({
type: "presence",
conversationId: subscribedConversationIdRef.current,
action: "left",
})
)
}
realtimeRef.current = null
realtime.disconnect()
subscribedConversationIdRef.current = null
}
}, [setRealtimeStatus, t])
useEffect(() => {
const socket = realtimeRef.current?.getSocket()
if (!socket || socket.readyState !== WebSocket.OPEN) {
return
}
const previousConversationId = subscribedConversationIdRef.current
const nextConversationId = selectedConversationId ?? null
if (previousConversationId && previousConversationId !== nextConversationId) {
socket.send(
JSON.stringify({
type: "presence",
conversationId: previousConversationId,
action: "left",
})
)
socket.send(
JSON.stringify({
type: "unsubscribe",
topics: [`conversation:${previousConversationId}`],
})
)
}
if (nextConversationId && nextConversationId !== previousConversationId) {
socket.send(
JSON.stringify({
type: "subscribe",
topics: [`conversation:${nextConversationId}`],
})
)
socket.send(
JSON.stringify({
type: "presence",
conversationId: nextConversationId,
action: "viewing",
})
)
subscribedConversationIdRef.current = nextConversationId
return
}
if (!nextConversationId) {
subscribedConversationIdRef.current = null
}
}, [selectedConversationId])
useEffect(() => {
const interval = setInterval(() => {
useAgentConversationsStore.getState().purgeExpiredOperatorPresences()
}, 5000)
return () => {
clearInterval(interval)
}
}, [])
}

25
web/hooks/use-lg-media.ts Normal file
View File

@@ -0,0 +1,25 @@
"use client";
import { useSyncExternalStore } from "react";
/** Matches Tailwind's default `lg` breakpoint (1024px). */
const LG_MEDIA_QUERY = "(min-width: 1024px)";
function subscribe(onStoreChange: () => void) {
const mq = window.matchMedia(LG_MEDIA_QUERY);
mq.addEventListener("change", onStoreChange);
return () => mq.removeEventListener("change", onStoreChange);
}
function getSnapshot() {
return window.matchMedia(LG_MEDIA_QUERY).matches;
}
function getServerSnapshot() {
return false;
}
/** Client-only true at lg and above. SSR always returns false for mobile-first rendering. */
export function useIsLgUp() {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

19
web/hooks/use-mobile.ts Normal file
View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

57
web/i18n/config.test.mjs Normal file
View File

@@ -0,0 +1,57 @@
import assert from "node:assert/strict"
import test from "node:test"
import ts from "typescript"
import { readFile } from "node:fs/promises"
import vm from "node:vm"
async function loadConfig() {
const source = await readFile(new URL("./config.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.CommonJS,
},
fileName: "config.ts",
})
const sandbox = {
exports: {},
module: { exports: {} },
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return sandbox.module.exports
}
test("normalizes supported locale aliases", async () => {
const { DEFAULT_LOCALE, normalizeLocale } = await loadConfig()
assert.equal(DEFAULT_LOCALE, "en-US")
assert.equal(normalizeLocale("zh-CN"), "zh-CN")
assert.equal(normalizeLocale("zh_CN"), "zh-CN")
assert.equal(normalizeLocale("zh"), "zh-CN")
assert.equal(normalizeLocale("en-US"), "en-US")
assert.equal(normalizeLocale("en_US"), "en-US")
assert.equal(normalizeLocale("en"), "en-US")
assert.equal(normalizeLocale("ru-RU"), "ru-RU")
assert.equal(normalizeLocale("ru_ru"), "ru-RU")
assert.equal(normalizeLocale("RU"), "ru-RU")
assert.equal(normalizeLocale("fr-FR"), DEFAULT_LOCALE)
})
test("lists every supported locale in SUPPORTED_LOCALES", async () => {
const { SUPPORTED_LOCALES } = await loadConfig()
assert.deepEqual([...SUPPORTED_LOCALES], ["zh-CN", "en-US", "ru-RU"])
})
test("reads the configured locale without browser language detection", async () => {
const { configureLocale, readStoredLocale } = await loadConfig()
assert.equal(readStoredLocale(), "en-US")
configureLocale("en-US")
assert.equal(readStoredLocale(), "en-US")
configureLocale("ru-RU")
assert.equal(readStoredLocale(), "ru-RU")
})

51
web/i18n/config.ts Normal file
View File

@@ -0,0 +1,51 @@
export const SUPPORTED_LOCALES = ["zh-CN", "en-US", "ru-RU"] as const
export type AppLocale = (typeof SUPPORTED_LOCALES)[number]
export const DEFAULT_LOCALE: AppLocale = "en-US"
const LOCALE_ALIASES: Record<string, AppLocale> = {
zh: "zh-CN",
"zh-cn": "zh-CN",
zh_cn: "zh-CN",
"zh-hans": "zh-CN",
en: "en-US",
"en-us": "en-US",
en_us: "en-US",
ru: "ru-RU",
"ru-ru": "ru-RU",
ru_ru: "ru-RU",
}
export function normalizeLocale(value: string | null | undefined): AppLocale {
return normalizeSupportedLocale(value) ?? DEFAULT_LOCALE
}
function normalizeSupportedLocale(
value: string | null | undefined
): AppLocale | null {
if (!value) {
return null
}
const key = value.trim().toLowerCase()
return LOCALE_ALIASES[key] ?? null
}
export function isSupportedLocale(
value: string | null | undefined
): value is AppLocale {
if (!value) {
return false
}
return SUPPORTED_LOCALES.includes(value as AppLocale)
}
let configuredLocale: AppLocale = DEFAULT_LOCALE
export function readStoredLocale(): AppLocale {
return configuredLocale
}
export function configureLocale(locale: string | null | undefined): AppLocale {
configuredLocale = normalizeLocale(locale)
return configuredLocale
}

View File

@@ -0,0 +1,91 @@
import assert from "node:assert/strict"
import test from "node:test"
import { readFile } from "node:fs/promises"
const SUPPORTED_LOCALES = ["zh-CN", "en-US", "ru-RU"]
async function loadMessages(locale) {
const source = await readFile(new URL(`../messages/${locale}.json`, import.meta.url), "utf8")
return JSON.parse(source)
}
function flatten(source, prefix = "", target = {}) {
for (const [key, value] of Object.entries(source)) {
const path = prefix ? `${prefix}.${key}` : key
if (value && typeof value === "object" && !Array.isArray(value)) {
flatten(value, path, target)
continue
}
target[path] = value
}
return target
}
function placeholders(value) {
return ((String(value).match(/\{(\w+)\}/g) ?? []).slice().sort().join("|"))
}
test("legal pages have localized document content", async () => {
for (const locale of SUPPORTED_LOCALES) {
const messages = await loadMessages(locale)
assert.equal(typeof messages.legal.terms.title, "string")
assert.equal(typeof messages.legal.privacy.title, "string")
assert.ok(messages.legal.terms.sections.length >= 6)
assert.ok(messages.legal.privacy.sections.length >= 6)
for (const page of [messages.legal.terms, messages.legal.privacy]) {
assert.equal(typeof page.updatedAt, "string")
assert.equal(typeof page.relatedLabel, "string")
assert.equal(typeof page.relatedLink, "string")
for (const section of page.sections) {
assert.equal(typeof section.title, "string")
assert.equal(typeof section.body, "string")
}
}
}
})
test("every locale bundle defines the same keys as the default locale", async () => {
const base = flatten(await loadMessages("zh-CN"))
for (const locale of SUPPORTED_LOCALES) {
const messages = flatten(await loadMessages(locale))
for (const key of Object.keys(base)) {
assert.ok(key in messages, `${locale}.json is missing key ${key}`)
}
for (const key of Object.keys(messages)) {
assert.ok(key in base, `${locale}.json has extra key ${key}`)
}
}
})
test("every locale keeps the same interpolation placeholders", async () => {
const base = flatten(await loadMessages("zh-CN"))
for (const locale of SUPPORTED_LOCALES) {
const messages = flatten(await loadMessages(locale))
for (const [key, value] of Object.entries(base)) {
if (typeof value !== "string" || Array.isArray(value)) {
continue
}
assert.equal(
placeholders(messages[key]),
placeholders(value),
`${locale}.json key ${key} placeholders differ`
)
}
}
})
test("locale labels exist for every supported locale", async () => {
for (const locale of SUPPORTED_LOCALES) {
const messages = await loadMessages(locale)
for (const supported of SUPPORTED_LOCALES) {
assert.equal(typeof messages.locale[supported], "string")
}
}
})

View File

@@ -0,0 +1,174 @@
import assert from "node:assert/strict"
import test from "node:test"
import { readFile, readdir } from "node:fs/promises"
import path from "node:path"
const WEB_ROOT = new URL("../", import.meta.url)
const LOCALES = ["zh-CN", "en-US", "ru-RU"]
const SOURCE_DIRS = ["app", "components", "lib"]
const SKIP_DIRS = new Set(["node_modules", ".next", "out", "public"])
async function collectSourceFiles(dir) {
const entries = await readdir(new URL(dir, WEB_ROOT), { withFileTypes: true })
const files = []
for (const entry of entries) {
if (entry.isDirectory()) {
if (!SKIP_DIRS.has(entry.name)) files.push(...(await collectSourceFiles(path.posix.join(dir, entry.name))))
continue
}
if (/\.(ts|tsx)$/.test(entry.name) && !/\.test\.mjs$/.test(entry.name)) {
files.push(path.posix.join(dir, entry.name))
}
}
return files
}
/**
* Finds duplicate keys inside any JSON object. JSON.parse keeps only the last
* occurrence, so a repeated top-level key silently deletes every sibling of the
* block that shadowed it - that is how the workflowRun namespace lost 39 keys.
*/
function findDuplicateKeys(text) {
const stack = []
const trail = []
const duplicates = []
let index = 0
let inString = false
let escaped = false
let stringStart = -1
let expectingKey = false
while (index < text.length) {
const char = text[index]
if (inString) {
if (escaped) escaped = false
else if (char === "\\") escaped = true
else if (char === '"') {
inString = false
const raw = text.slice(stringStart + 1, index)
const top = stack[stack.length - 1]
if (expectingKey && top && !top.isArray) {
if (top.keys.has(raw)) duplicates.push([...trail, raw].join("."))
else top.keys.add(raw)
expectingKey = false
}
}
index += 1
continue
}
if (char === '"') {
inString = true
escaped = false
stringStart = index
index += 1
continue
}
if (char === "{") {
stack.push({ isArray: false, keys: new Set() })
trail.push("<object>")
expectingKey = true
} else if (char === "[") {
stack.push({ isArray: true, keys: new Set() })
trail.push("<array>")
expectingKey = false
} else if (char === "}" || char === "]") {
stack.pop()
trail.pop()
expectingKey = false
} else if (char === ",") {
const top = stack[stack.length - 1]
expectingKey = Boolean(top) && !top.isArray
} else if (char === ":") {
expectingKey = false
}
index += 1
}
return duplicates
}
test("no message bundle declares the same key twice", async () => {
for (const locale of LOCALES) {
const source = await readFile(new URL(`messages/${locale}.json`, WEB_ROOT), "utf8")
assert.deepEqual(findDuplicateKeys(source), [], `${locale}.json has duplicate keys`)
}
})
// Keys the UI resolves through t()/translateMessage(), plus the data-driven
// titleKey-style props. Scanning only t() calls misses lib/navigation.tsx,
// which is where the reported sidebar gaps lived.
const KEY_PATTERNS = [
/\b(?:t|translateCurrentMessage|translateMessage)\(\s*["'`]([A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+)["'`]/g,
/\b(?:titleKey|labelKey|descriptionKey|textKey|placeholderKey|ariaLabelKey|tooltipKey):\s*["'`]([A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+)["'`]/g,
]
test("every key the UI asks for exists in every locale", async () => {
const bundles = {}
for (const locale of LOCALES) {
bundles[locale] = JSON.parse(await readFile(new URL(`messages/${locale}.json`, WEB_ROOT), "utf8"))
}
const lookup = (bundle, key) => key.split(".").reduce((value, segment) => (value == null ? undefined : value[segment]), bundle)
const files = (await Promise.all(SOURCE_DIRS.map((dir) => collectSourceFiles(dir)))).flat()
const missing = []
for (const file of files) {
const source = await readFile(new URL(file, WEB_ROOT), "utf8")
for (const pattern of KEY_PATTERNS) {
for (const match of source.matchAll(pattern)) {
for (const locale of LOCALES) {
if (lookup(bundles[locale], match[1]) === undefined) {
missing.push(`${file}: ${match[1]} (missing in ${locale})`)
}
}
}
}
}
assert.deepEqual([...new Set(missing)], [])
})
// Template keys such as `supportPublic.comment.sort.${sort}` cannot be resolved
// by scanning source, so their known value sets are pinned here.
const DYNAMIC_KEYS = {
"supportPublic.comment.sort": ["default", "hot", "latest"],
"supportPublic.help": ["previous", "next"],
supportChat: ["connecting", "connected", "disconnected"],
agentTeamSchedule: ["weekdayMon", "weekdayTue", "weekdayWed", "weekdayThu", "weekdayFri", "weekdaySat", "weekdaySun"],
supportHelpWorkbench: [
"publishedAction",
"publishedConfirmTitle",
"publishedConfirmDescription",
"publishedSuccess",
"draftAction",
"draftConfirmTitle",
"draftConfirmDescription",
"draftSuccess",
"hiddenAction",
"hiddenConfirmTitle",
"hiddenConfirmDescription",
"hiddenSuccess",
],
}
test("every dynamically composed key variant exists in every locale", async () => {
const bundles = {}
for (const locale of LOCALES) {
bundles[locale] = JSON.parse(await readFile(new URL(`messages/${locale}.json`, WEB_ROOT), "utf8"))
}
const lookup = (bundle, key) => key.split(".").reduce((value, segment) => (value == null ? undefined : value[segment]), bundle)
const missing = []
for (const [prefix, variants] of Object.entries(DYNAMIC_KEYS)) {
for (const variant of variants) {
for (const locale of LOCALES) {
if (lookup(bundles[locale], `${prefix}.${variant}`) === undefined) {
missing.push(`${prefix}.${variant} (missing in ${locale})`)
}
}
}
}
assert.deepEqual(missing, [])
})

53
web/i18n/messages.ts Normal file
View File

@@ -0,0 +1,53 @@
import { DEFAULT_LOCALE, type AppLocale, readStoredLocale } from "@/i18n/config"
import enUSMessages from "@/messages/en-US.json"
import ruRUMessages from "@/messages/ru-RU.json"
import zhCNMessages from "@/messages/zh-CN.json"
const messages = {
"zh-CN": zhCNMessages,
"en-US": enUSMessages,
"ru-RU": ruRUMessages,
} satisfies Record<AppLocale, typeof zhCNMessages>
export function translateMessage(
locale: AppLocale,
key: string,
values?: Record<string, string | number>
): string {
const value = getMessageValue(messages[locale], key)
if (typeof value === "string") {
return formatMessage(value, values)
}
const fallback = getMessageValue(messages[DEFAULT_LOCALE], key)
return typeof fallback === "string" ? formatMessage(fallback, values) : key
}
export function translateCurrentMessage(
key: string,
values?: Record<string, string | number>
): string {
return translateMessage(readStoredLocale(), key, values)
}
function getMessageValue(source: unknown, key: string): unknown {
let current = source
for (const part of key.split(".")) {
if (!current || typeof current !== "object" || !(part in current)) {
return undefined
}
current = (current as Record<string, unknown>)[part]
}
return current
}
function formatMessage(
message: string,
values?: Record<string, string | number>
): string {
if (!values) {
return message
}
return message.replace(/\{(\w+)\}/g, (match, key) =>
Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : match
)
}

87
web/i18n/provider.tsx Normal file
View File

@@ -0,0 +1,87 @@
"use client"
import {
createContext,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react"
import {
DEFAULT_LOCALE,
type AppLocale,
configureLocale,
} from "@/i18n/config"
import { translateMessage } from "@/i18n/messages"
import { fetchPublicConfig } from "@/lib/api/config"
type LocaleContextValue = {
locale: AppLocale
setLocale: (locale: AppLocale) => void
t: (key: string, values?: Record<string, string | number>) => string
}
const LocaleContext = createContext<LocaleContextValue>({
locale: DEFAULT_LOCALE,
setLocale: () => {},
t: (key) => key,
})
export function AppI18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<AppLocale>(DEFAULT_LOCALE)
const [isLocaleReady, setIsLocaleReady] = useState(false)
useEffect(() => {
let cancelled = false
fetchPublicConfig()
.then((config) => configureLocale(config.language))
.catch(() => configureLocale(DEFAULT_LOCALE))
.then((configuredLocale) => {
if (cancelled) {
return
}
setLocaleState(configuredLocale)
document.documentElement.lang = configuredLocale
document.title = translateMessage(configuredLocale, "app.metadataTitle")
setIsLocaleReady(true)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
document.title = translateMessage(locale, "app.metadataTitle")
}, [locale])
const value = useMemo<LocaleContextValue>(
() => ({
locale,
t: (key, values) => translateMessage(locale, key, values),
setLocale: () => {},
}),
[locale]
)
if (!isLocaleReady) {
return null
}
return (
<LocaleContext.Provider value={value}>
{children}
</LocaleContext.Provider>
)
}
export function useAppLocale() {
return useContext(LocaleContext)
}
export function useI18n() {
return useContext(LocaleContext).t
}

View File

@@ -0,0 +1,107 @@
import test from "node:test"
import assert from "node:assert/strict"
import { getAgentActivityLabel, formatOperatorToolName } from "./agent-activity-label.ts"
// Mock translator that mimics t(key, params)
function mockT(key, params = {}) {
const dictionary = {
"agentActivity.thinking": "AI-ассистент думает...",
"agentActivity.searchingKnowledge": "Поиск в базе знаний...",
"agentActivity.callingTool": `Выполнение действия: ${params.name || "..."}...`,
"agentActivity.runningWorkflow": `Выполнение сценария: ${params.name || "..."}...`,
"agentActivity.default": "AI-ассистент обрабатывает запрос...",
"agentActivity.customerThinking": "AI-ассистент думает...",
"agentActivity.customerSearching": "Поиск информации...",
"agentActivity.customerProcessing": "AI-ассистент готовит ответ...",
"agentActivity.customerDefault": "AI-ассистент готовит ответ...",
"agentActivity.toolAnalyzeConversation": "Анализ диалога",
"agentActivity.toolTriageServiceRequest": "Маршрутизация обращения",
"agentActivity.toolCreateTicket": "Создание обращения",
"agentActivity.toolHandoff": "Перевод на оператора",
}
return dictionary[key] ?? key
}
test("formatOperatorToolName formats known graph tools to localized human titles", () => {
assert.equal(formatOperatorToolName("graph_analyze_conversation", mockT), "Анализ диалога")
assert.equal(formatOperatorToolName("graph/analyze_conversation", mockT), "Анализ диалога")
assert.equal(formatOperatorToolName("analyze_conversation", mockT), "Анализ диалога")
assert.equal(formatOperatorToolName("graph_triage_service_request", mockT), "Маршрутизация обращения")
assert.equal(formatOperatorToolName("graph_create_ticket", mockT), "Создание обращения")
assert.equal(formatOperatorToolName("graph_handoff", mockT), "Перевод на оператора")
})
test("formatOperatorToolName formats unknown tools by stripping prefixes and converting underscores", () => {
assert.equal(formatOperatorToolName("mcp_fetch_order_status", mockT), "mcp fetch order status")
assert.equal(formatOperatorToolName("workflow_refund_check", mockT), "refund check")
assert.equal(formatOperatorToolName("skill_order_lookup", mockT), "order lookup")
assert.equal(formatOperatorToolName("", mockT), "...")
assert.equal(formatOperatorToolName(undefined, mockT), "...")
})
test("customer mode (isCustomer=true) never exposes tool names or technical system jargon", () => {
// Calling tool with internal graph_analyze_conversation
const toolLabel = getAgentActivityLabel(
{ stepCode: "calling_tool", toolName: "graph_analyze_conversation", isCustomer: true },
mockT
)
assert.equal(toolLabel, "AI-ассистент готовит ответ...")
assert.ok(!toolLabel.includes("graph_analyze_conversation"))
assert.ok(!toolLabel.includes("Выполнение действия"))
// Running workflow
const workflowLabel = getAgentActivityLabel(
{ stepCode: "running_workflow", toolName: "workflow_triage_request", isCustomer: true },
mockT
)
assert.equal(workflowLabel, "AI-ассистент готовит ответ...")
assert.ok(!workflowLabel.includes("workflow_triage_request"))
// Searching knowledge
const searchLabel = getAgentActivityLabel(
{ stepCode: "searching_knowledge", isCustomer: true },
mockT
)
assert.equal(searchLabel, "Поиск информации...")
assert.ok(!searchLabel.includes("базе знаний"))
// Thinking
const thinkingLabel = getAgentActivityLabel(
{ stepCode: "thinking", isCustomer: true },
mockT
)
assert.equal(thinkingLabel, "AI-ассистент думает...")
// Defaults to customer mode if isCustomer is omitted
const defaultCustomerLabel = getAgentActivityLabel(
{ stepCode: "calling_tool", toolName: "graph_analyze_conversation" },
mockT
)
assert.equal(defaultCustomerLabel, "AI-ассистент готовит ответ...")
})
test("operator mode (isCustomer=false) provides clear formatted context to operators", () => {
const toolLabel = getAgentActivityLabel(
{ stepCode: "calling_tool", toolName: "graph_analyze_conversation", isCustomer: false },
mockT
)
assert.equal(toolLabel, "Выполнение действия: Анализ диалога...")
const workflowLabel = getAgentActivityLabel(
{ stepCode: "running_workflow", toolName: "workflow_sync_data", isCustomer: false },
mockT
)
assert.equal(workflowLabel, "Выполнение сценария: sync data...")
const searchLabel = getAgentActivityLabel(
{ stepCode: "searching_knowledge", isCustomer: false },
mockT
)
assert.equal(searchLabel, "Поиск в базе знаний...")
const thinkingLabel = getAgentActivityLabel(
{ stepCode: "thinking", isCustomer: false },
mockT
)
assert.equal(thinkingLabel, "AI-ассистент думает...")
})

View File

@@ -0,0 +1,88 @@
export type AgentActivityLabelOptions = {
stepCode: string
toolName?: string
isCustomer?: boolean
}
export type TranslateFn = (key: string, values?: Record<string, string>) => string
export function formatOperatorToolName(toolName?: string, t?: TranslateFn): string {
if (!toolName) return "..."
const normalized = toolName.trim()
if (
normalized === "graph_analyze_conversation" ||
normalized === "analyze_conversation" ||
normalized === "graph/analyze_conversation"
) {
return t ? t("agentActivity.toolAnalyzeConversation") : "Analyze conversation"
}
if (
normalized === "graph_triage_service_request" ||
normalized === "triage_service_request" ||
normalized === "graph/triage_service_request"
) {
return t ? t("agentActivity.toolTriageServiceRequest") : "Triage service request"
}
if (
normalized === "graph_create_ticket" ||
normalized === "create_ticket" ||
normalized === "graph/create_ticket"
) {
return t ? t("agentActivity.toolCreateTicket") : "Create ticket"
}
if (
normalized === "graph_handoff" ||
normalized === "handoff" ||
normalized === "graph/handoff"
) {
return t ? t("agentActivity.toolHandoff") : "Human handoff"
}
// Remove common prefixes: graph_, skill_, workflow_, builtin_, or graph/
const clean = normalized
.replace(/^(graph_|skill_|workflow_|builtin_|graph\/)/, "")
.replace(/_/g, " ")
.trim()
return clean || normalized
}
export function getAgentActivityLabel(
options: AgentActivityLabelOptions,
t: TranslateFn
): string {
const { stepCode, toolName, isCustomer = true } = options
// For customers (end-users in support chat):
// Never expose internal system details, technical function names, or tool execution steps.
if (isCustomer) {
if (stepCode === "thinking") {
return t("agentActivity.customerThinking")
}
if (stepCode === "searching_knowledge") {
return t("agentActivity.customerSearching")
}
// For calling_tool, running_workflow, or any other internal action
return t("agentActivity.customerProcessing")
}
// For operators (in dashboard workspace):
// Surface informative step and formatted tool name
if (stepCode === "thinking") {
return t("agentActivity.thinking")
}
if (stepCode === "searching_knowledge") {
return t("agentActivity.searchingKnowledge")
}
if (stepCode === "calling_tool") {
const formatted = formatOperatorToolName(toolName, t)
return t("agentActivity.callingTool", { name: formatted })
}
if (stepCode === "running_workflow") {
const formatted = formatOperatorToolName(toolName, t)
return t("agentActivity.runningWorkflow", { name: formatted })
}
return t("agentActivity.default")
}

View File

@@ -0,0 +1,74 @@
import test from "node:test"
import assert from "node:assert/strict"
import { resolveAgentActivityState } from "./agent-activity-state.ts"
test("sets activity state when event matches active conversation and status is running", () => {
const current = null
const event = {
conversationId: 42,
stepCode: "searching_knowledge",
status: "running",
}
const next = resolveAgentActivityState(current, 42, event)
assert.deepEqual(next, { conversationId: 42, stepCode: "searching_knowledge" })
})
test("clears activity state when status is completed or failed", () => {
const current = { conversationId: 42, stepCode: "thinking" }
const event = {
conversationId: 42,
stepCode: "completed",
status: "completed",
}
const next = resolveAgentActivityState(current, 42, event)
assert.equal(next, null)
})
test("clears activity state when status is failed", () => {
const current = { conversationId: 42, stepCode: "thinking" }
const event = {
conversationId: 42,
stepCode: "failed",
status: "failed",
}
const next = resolveAgentActivityState(current, 42, event)
assert.equal(next, null)
})
test("ignores events belonging to a different conversation", () => {
const current = null
const event = {
conversationId: 99,
stepCode: "thinking",
status: "started",
}
const next = resolveAgentActivityState(current, 42, event)
assert.equal(next, null)
})
test("preserves existing activity when event is for another conversation", () => {
const current = { conversationId: 42, stepCode: "thinking" }
const event = {
conversationId: 99,
stepCode: "calling_tool",
status: "running",
}
const next = resolveAgentActivityState(current, 42, event)
assert.deepEqual(next, current)
})
test("preserves toolName when provided", () => {
const current = null
const event = {
conversationId: 42,
stepCode: "calling_tool",
toolName: "web_search",
status: "running",
}
const next = resolveAgentActivityState(current, 42, event)
assert.deepEqual(next, {
conversationId: 42,
stepCode: "calling_tool",
toolName: "web_search",
})
})

View File

@@ -0,0 +1,36 @@
export type AgentActivityInfo = {
conversationId: number
stepCode: string
toolName?: string
}
export type AgentActivityEventPayload = {
conversationId?: number
stepCode?: string
toolName?: string
status?: string
}
export function resolveAgentActivityState(
current: AgentActivityInfo | null,
activeConversationId: number | undefined,
payload: AgentActivityEventPayload | null | undefined
): AgentActivityInfo | null {
if (
!payload ||
!activeConversationId ||
!payload.conversationId ||
payload.conversationId !== activeConversationId ||
!payload.stepCode
) {
return current
}
if (payload.status === "completed" || payload.status === "failed") {
return null
}
return {
conversationId: payload.conversationId,
stepCode: payload.stepCode,
...(payload.toolName ? { toolName: payload.toolName } : {}),
}
}

View File

@@ -0,0 +1,27 @@
import assert from "node:assert/strict"
import test from "node:test"
import { shouldReloadConversationListForRealtimePatch } from "./agent-conversation-realtime.ts"
test("reloads conversation list when realtime patch changes list membership fields", () => {
assert.equal(
shouldReloadConversationListForRealtimePatch({
conversationId: 1,
status: 3,
currentAssigneeId: 101,
}),
true
)
})
test("keeps local patching for message summary and unread-only changes", () => {
assert.equal(
shouldReloadConversationListForRealtimePatch({
conversationId: 1,
lastMessageId: 9,
lastMessageSummary: "hello",
agentUnreadCount: 1,
}),
false
)
})

View File

@@ -0,0 +1,18 @@
import type { RealtimeConversationPatch } from "@/lib/im-realtime-state"
const listMembershipFields = new Set<keyof RealtimeConversationPatch>([
"status",
"currentAssigneeId",
"currentTeamId",
])
export function shouldReloadConversationListForRealtimePatch(
patch: RealtimeConversationPatch | null | undefined
) {
if (!patch) {
return false
}
return Object.keys(patch).some((key) =>
listMembershipFields.has(key as keyof RealtimeConversationPatch)
)
}

2654
web/lib/api/admin.ts Normal file

File diff suppressed because it is too large Load Diff

277
web/lib/api/agent.ts Normal file
View File

@@ -0,0 +1,277 @@
import { request } from "@/lib/api/client"
import type { ConversationSatisfactionResponse } from "@/lib/api/im"
export type Paging = {
page: number
limit: number
total: number
}
export type PageResult<T> = {
results: T[]
page: Paging
}
export type CursorResult<T> = {
results: T[]
cursor: string
hasMore: boolean
}
export type AgentConversationTag = {
id: number
name: string
}
export type AgentConversationParticipant = {
id: number
participantType: string
participantId: number
externalParticipantId?: string
joinedAt?: string
leftAt?: string
status: number
}
export type AgentConversation = {
id: number
aiAgentId?: number
channelId?: number
customerId?: number
customerName: string
status: number
serviceMode: number
priority: number
currentAssigneeId: number
currentAssigneeName?: string
currentTeamId: number
currentTeamName?: string
lastMessageId: number
lastMessageAt?: string
lastActiveAt?: string
lastMessageSummary?: string
customerUnreadCount: number
agentUnreadCount: number
customerLastReadMessageId: number
customerLastReadAt?: string
agentLastReadMessageId: number
agentLastReadAt?: string
customerOnline: boolean
closedAt?: string
tags?: AgentConversationTag[]
participants?: AgentConversationParticipant[]
satisfaction?: ConversationSatisfactionResponse | null
}
export type AgentConversationDetail = AgentConversation & {
participants?: AgentConversationParticipant[]
}
export type AgentMessage = {
id: number
conversationId: number
workflowRunId?: number
clientMsgId?: string
senderType: string
senderId: number
senderName?: string
senderAvatar?: string
messageType: string
content: string
payload?: string
sendStatus: number
sentAt?: string
deliveredAt?: string
readAt?: string
customerRead: boolean
customerReadAt?: string
agentRead: boolean
agentReadAt?: string
recalledAt?: string
quotedMessageId?: number
}
export type AgentAsset = {
id: number
assetId: string
provider: string
storageKey: string
filename: string
fileSize: number
mimeType: string
status: number
url: string
createdAt: string
updatedAt: string
createUserId: number
createUserName: string
updateUserId: number
updateUserName: string
}
function toQueryString(query?: Record<string, string | number | undefined>) {
if (!query) {
return ""
}
const params = new URLSearchParams()
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === "") {
return
}
params.set(key, String(value))
})
const output = params.toString()
return output ? `?${output}` : ""
}
export function fetchAgentConversations(
query?: Record<string, string | number | undefined>
) {
return request<PageResult<AgentConversation>>(
`/api/dashboard/conversation/conversations${toQueryString(query)}`
)
}
export function fetchAgentConversationDetail(id: number) {
return request<AgentConversationDetail>(`/api/dashboard/conversation/${id}`)
}
export function fetchAgentMessages(
query?: Record<string, string | number | undefined>
) {
return request<CursorResult<AgentMessage>>(
`/api/dashboard/conversation/message_list${toQueryString(query)}`
)
}
export function sendAgentMessage(payload: {
conversationId: number
messageType: string
content: string
payload?: string
clientMsgId?: string
}) {
return request<AgentMessage>("/api/dashboard/conversation/send_message", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function recallAgentMessage(messageId: number) {
return request<AgentMessage>("/api/dashboard/conversation/recall_message", {
method: "POST",
body: JSON.stringify({ messageId }),
})
}
export function markAgentMessageRead(conversationId: number, messageId = 0) {
return request<void>("/api/dashboard/conversation/read", {
method: "POST",
body: JSON.stringify({ conversationId, messageId }),
})
}
export function uploadAgentConversationImage(conversationId: number, file: File) {
const formData = new FormData()
formData.set("conversationId", String(conversationId))
formData.set("file", file)
return request<AgentAsset>("/api/dashboard/conversation/upload_image", {
method: "POST",
body: formData,
})
}
export function uploadAgentConversationAttachment(conversationId: number, file: File) {
const formData = new FormData()
formData.set("conversationId", String(conversationId))
formData.set("file", file)
return request<AgentAsset>("/api/dashboard/conversation/upload_attachment", {
method: "POST",
body: formData,
})
}
export function closeAgentConversation(
conversationId: number,
closeReason: string
) {
return request<void>("/api/dashboard/conversation/close", {
method: "POST",
body: JSON.stringify({ conversationId, closeReason }),
})
}
export function assignAgentConversation(
conversationId: number,
assigneeId: number,
reason: string
) {
return request<void>("/api/dashboard/conversation/assign", {
method: "POST",
body: JSON.stringify({ conversationId, assigneeId, reason }),
})
}
export function transferAgentConversation(
conversationId: number,
toUserId: number,
reason: string
) {
return request<void>("/api/dashboard/conversation/transfer", {
method: "POST",
body: JSON.stringify({ conversationId, toUserId, reason }),
})
}
export function linkConversationToCustomer(payload: {
conversationId: number
customerId: number
}) {
return request<void>("/api/dashboard/conversation/link_customer", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function addConversationTag(payload: {
conversationId: number
tagId: number
}) {
return request<void>("/api/dashboard/conversation/add_tag", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function removeConversationTag(payload: {
conversationId: number
tagId: number
}) {
return request<void>("/api/dashboard/conversation/remove_tag", {
method: "POST",
body: JSON.stringify(payload),
})
}
export async function suggestAgentReply(conversationId: number): Promise<string> {
const res = await request<{ suggestion: string }>(
"/api/dashboard/conversation/suggest_reply",
{
method: "POST",
body: JSON.stringify({ conversationId }),
}
)
return res?.suggestion ?? ""
}
export async function summarizeAgentConversation(conversationId: number): Promise<string> {
const res = await request<{ summary: string }>(
"/api/dashboard/conversation/summarize",
{
method: "POST",
body: JSON.stringify({ conversationId }),
}
)
return res?.summary ?? ""
}

75
web/lib/api/auth.ts Normal file
View File

@@ -0,0 +1,75 @@
import { clearSession, readSession, writeSession, type AuthSession } from "@/lib/auth"
import { request } from "@/lib/api/client"
export type LoginRequest = {
username: string
password: string
}
export type UpdateProfileRequest = {
avatar: string
email?: string
nickname: string
}
export type ProfileAvatarAsset = {
url: string
}
export async function loginWithPassword(payload: LoginRequest) {
const data = await request<AuthSession>("/api/auth/login", {
method: "POST",
body: JSON.stringify(payload),
skipAuth: true,
})
writeSession(data)
return data
}
export async function exchangeOIDCTicket(ticket: string) {
const data = await request<AuthSession>("/api/auth/oidc_exchange", {
method: "POST",
body: JSON.stringify({ ticket }),
skipAuth: true,
})
writeSession(data)
return data
}
export async function fetchProfile() {
return request<AuthSession>("/api/auth/profile")
}
export async function updateProfile(payload: UpdateProfileRequest) {
const data = await request<AuthSession>("/api/auth/profile/update", {
method: "POST",
body: JSON.stringify(payload),
})
const stored = readSession()
const nextSession: AuthSession = {
...data,
accessToken: data.accessToken || stored?.accessToken || "",
expiresAt: data.expiresAt || stored?.expiresAt,
}
writeSession(nextSession)
return nextSession
}
export function uploadProfileAvatar(file: File) {
const formData = new FormData()
formData.set("file", file)
return request<ProfileAvatarAsset>("/api/auth/profile/avatar/upload", {
method: "POST",
body: formData,
})
}
export async function logout() {
try {
await request("/api/auth/logout", {
method: "POST",
})
} finally {
clearSession()
}
}

152
web/lib/api/client.ts Normal file
View File

@@ -0,0 +1,152 @@
import { expireSession, readSession } from "@/lib/auth"
import { translateCurrentMessage } from "@/i18n/messages"
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() || ""
type JsonResult<T> = {
errorCode: number
message: string
data: T
success: boolean
}
type RequestOptions = RequestInit & {
skipAuth?: boolean
baseUrl?: string
onResponse?: (response: Response) => void
}
export type BlobResponse = {
blob: Blob
filename: string
}
export class ApiRequestError extends Error {
errorCode?: number
data?: unknown
constructor(message: string, errorCode?: number, data?: unknown, options?: ErrorOptions) {
super(message, options)
this.name = "ApiRequestError"
this.errorCode = errorCode
this.data = data
}
}
export function isApiRequestError(error: unknown): error is ApiRequestError {
return error instanceof ApiRequestError
}
function normalizeApiRequestError(error: unknown) {
if (isApiRequestError(error)) {
return error
}
return new ApiRequestError(
translateCurrentMessage("api.requestFailed"),
undefined,
undefined,
{ cause: error }
)
}
async function parseResult<T>(response: Response) {
const payload = (await response.json()) as JsonResult<T>
if (!response.ok || !payload.success) {
if (payload.errorCode === 3000 || payload.errorCode === 3002) {
expireSession()
}
throw new ApiRequestError(
payload.message || translateCurrentMessage("api.requestFailed"),
payload.errorCode,
payload.data
)
}
return payload.data
}
function buildRequestHeaders(headers: HeadersInit | undefined, skipAuth?: boolean, body?: BodyInit | null) {
const session = readSession()
const authHeaders = new Headers(headers)
if (!skipAuth && session?.accessToken) {
authHeaders.set("Authorization", `Bearer ${session.accessToken}`)
}
if (
!authHeaders.has("Content-Type") &&
body &&
!(typeof FormData !== "undefined" && body instanceof FormData)
) {
authHeaders.set("Content-Type", "application/json")
}
return authHeaders
}
function parseFilename(contentDisposition: string | null) {
if (!contentDisposition) {
return ""
}
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)
if (utf8Match?.[1]) {
return decodeURIComponent(utf8Match[1])
}
const match = contentDisposition.match(/filename="?([^";]+)"?/i)
return match?.[1] ? decodeURIComponent(match[1]) : ""
}
export async function request<T>(
path: string,
options: RequestOptions = {}
): Promise<T> {
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
delete (rest as RequestOptions).baseUrl
delete (rest as RequestOptions).onResponse
const authHeaders = buildRequestHeaders(headers, skipAuth, rest.body)
const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
try {
const response = await fetch(`${requestBaseUrl}${path}`, {
...rest,
headers: authHeaders,
cache: "no-store",
})
onResponse?.(response)
return await parseResult<T>(response)
} catch (error) {
throw normalizeApiRequestError(error)
}
}
export async function requestBlob(
path: string,
options: RequestOptions = {}
): Promise<BlobResponse> {
const { headers, skipAuth, baseUrl, onResponse, ...rest } = options
delete (rest as RequestOptions).baseUrl
delete (rest as RequestOptions).onResponse
const authHeaders = buildRequestHeaders(headers, skipAuth, rest.body)
const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
const response = await fetch(`${requestBaseUrl}${path}`, {
...rest,
headers: authHeaders,
cache: "no-store",
})
onResponse?.(response)
const contentType = response.headers.get("Content-Type") ?? ""
if (contentType.includes("application/json")) {
try {
await parseResult<never>(response)
} catch (error) {
throw error
}
throw new ApiRequestError(translateCurrentMessage("api.requestFailed"))
}
if (!response.ok) {
throw new ApiRequestError(response.statusText || translateCurrentMessage("api.requestFailed"))
}
return {
blob: await response.blob(),
filename: parseFilename(response.headers.get("Content-Disposition")),
}
}

77
web/lib/api/company.ts Normal file
View File

@@ -0,0 +1,77 @@
import { request } from "@/lib/api/client"
import type { PageResult } from "@/lib/api/admin"
export type AdminCompany = {
id: number
name: string
code: string
customerCount: number
status: number
remark: string
createdAt: string
updatedAt: string
}
export type CreateAdminCompanyPayload = {
name: string
code: string
remark: string
}
export type UpdateAdminCompanyPayload = CreateAdminCompanyPayload & {
id: number
}
function toQueryString(query?: Record<string, string | number | undefined>) {
if (!query) {
return ""
}
const params = new URLSearchParams()
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === "") {
return
}
params.set(key, String(value))
})
const output = params.toString()
return output ? `?${output}` : ""
}
export function fetchCompanies(query?: Record<string, string | number | undefined>) {
return request<PageResult<AdminCompany>>(
`/api/dashboard/company/list${toQueryString(query)}`
)
}
export function fetchCompany(id: number) {
return request<AdminCompany>(`/api/dashboard/company/${id}`)
}
export function createCompany(payload: CreateAdminCompanyPayload) {
return request<AdminCompany>("/api/dashboard/company/create", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function updateCompany(payload: UpdateAdminCompanyPayload) {
return request<void>("/api/dashboard/company/update", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function updateCompanyStatus(id: number, status: number) {
return request<void>("/api/dashboard/company/update_status", {
method: "POST",
body: JSON.stringify({ id, status }),
})
}
export function deleteCompany(id: number) {
return request<void>("/api/dashboard/company/delete", {
method: "POST",
body: JSON.stringify({ id }),
})
}

21
web/lib/api/config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { request } from "@/lib/api/client"
export type PublicConfig = {
language: string
companyName?: string
companyLogoUrl?: string
passwordLoginEnabled?: boolean
oidcEnabled: boolean
}
let publicConfigPromise: Promise<PublicConfig> | null = null
export function fetchPublicConfig() {
publicConfigPromise ??= request<PublicConfig>("/api/config", {
skipAuth: true,
}).catch((error) => {
publicConfigPromise = null
throw error
})
return publicConfigPromise
}

View File

@@ -0,0 +1,77 @@
import { request } from "@/lib/api/client"
import type { ContactType } from "@/lib/generated/enums"
export type AdminCustomerContact = {
id: number
customerId: number
contactType: ContactType | string
contactValue: string
isPrimary: boolean
isVerified: boolean
verifiedAt?: string
source: string
status: number
remark: string
createdAt: string
updatedAt: string
}
export type CreateCustomerContactPayload = {
customerId: number
contactType: ContactType | string
contactValue: string
isPrimary: boolean
isVerified: boolean
source: string
status: number
remark: string
}
export type UpdateCustomerContactPayload = Omit<
CreateCustomerContactPayload,
"customerId"
> & {
id: number
}
function toQueryString(query?: Record<string, string | number | undefined>) {
if (!query) {
return ""
}
const params = new URLSearchParams()
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === "") {
return
}
params.set(key, String(value))
})
const output = params.toString()
return output ? `?${output}` : ""
}
export function fetchCustomerContacts(customerId: number) {
return request<AdminCustomerContact[]>(
`/api/dashboard/customer-contact/list${toQueryString({ customerId })}`
)
}
export function createCustomerContact(payload: CreateCustomerContactPayload) {
return request<AdminCustomerContact>("/api/dashboard/customer-contact/create", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function updateCustomerContact(payload: UpdateCustomerContactPayload) {
return request<void>("/api/dashboard/customer-contact/update", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function deleteCustomerContact(id: number) {
return request<void>("/api/dashboard/customer-contact/delete", {
method: "POST",
body: JSON.stringify({ id }),
})
}

108
web/lib/api/customer.ts Normal file
View File

@@ -0,0 +1,108 @@
import { request } from "@/lib/api/client"
import type { PageResult } from "@/lib/api/admin"
import type { ContactType } from "@/lib/generated/enums"
import { AdminCompany } from "./company"
export type AdminCustomer = {
id: number
name: string
gender: number
companyId: number
company?: AdminCompany
lastActiveAt?: string
primaryMobile: string
primaryEmail: string
status: number
remark: string
createdAt: string
updatedAt: string
}
export type CreateAdminCustomerPayload = {
name: string
gender: number
companyId: number
primaryMobile: string
primaryEmail: string
remark: string
}
export type UpdateAdminCustomerPayload = CreateAdminCustomerPayload & {
id: number
}
/** Matches POST /customer/save/profile request body. */
export type SaveCustomerProfileContactLine = {
id?: number
contactType: ContactType | string
contactValue: string
remark: string
isPrimary: boolean
}
export type SaveCustomerProfilePayload = {
id?: number
name: string
gender: number
companyId: number
remark: string
contacts: SaveCustomerProfileContactLine[]
}
/** Matches POST /customer/list JSON body. */
export type CustomerListRequest = {
page: number
limit: number
status?: number
gender?: number
companyId?: number
/** Fuzzy match against customer name, primary phone, primary email, contacts, and company name. */
keyword?: string
}
export function fetchCustomers(body: CustomerListRequest) {
return request<PageResult<AdminCustomer>>("/api/dashboard/customer/list", {
method: "POST",
body: JSON.stringify(body),
})
}
export function fetchCustomer(id: number) {
return request<AdminCustomer | null>(`/api/dashboard/customer/${id}`)
}
export function createCustomer(payload: CreateAdminCustomerPayload) {
return request<AdminCustomer>("/api/dashboard/customer/create", {
method: "POST",
body: JSON.stringify(payload),
})
}
/** Saves the customer profile and the full contact list in one request and transaction. */
export function saveCustomerProfile(payload: SaveCustomerProfilePayload) {
return request<AdminCustomer>("/api/dashboard/customer/save_profile", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function updateCustomer(payload: UpdateAdminCustomerPayload) {
return request<void>("/api/dashboard/customer/update", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function updateCustomerStatus(id: number, status: number) {
return request<void>("/api/dashboard/customer/update_status", {
method: "POST",
body: JSON.stringify({ id, status }),
})
}
export function deleteCustomer(id: number) {
return request<void>("/api/dashboard/customer/delete", {
method: "POST",
body: JSON.stringify({ id }),
})
}

81
web/lib/api/dashboard.ts Normal file
View File

@@ -0,0 +1,81 @@
import { request } from "@/lib/api/client"
export type DashboardRange = "today" | "7d" | "30d"
export type DashboardStatusDistributionItem = {
status: number
label: string
count: number
}
export type DashboardTrendItem = {
date: string
newCount: number
closedCount: number
}
export type DashboardTeamLoad = {
teamId: number
teamName: string
totalAgents: number
onlineAgents: number
busyAgents: number
offlineAgents: number
waitingConversations: number
processingConversations: number
maxConcurrentCapacity: number
loadRate: number
hasScheduleNow: boolean
}
export type DashboardAlert = {
id: string
level: "info" | "warning" | "error"
title: string
description: string
count: number
link: string
}
export type DashboardQuickLink = {
title: string
description: string
link: string
}
export type DashboardOverview = {
range: DashboardRange
generatedAt: string
summary: {
todayNewConversations: number
processingConversations: number
pendingDispatchConversations: number
onlineAgents: number
aiServiceRate: number
}
conversationStats: {
statusDistribution: DashboardStatusDistributionItem[]
trend: DashboardTrendItem[]
}
agentStats: {
onlineAgents: number
busyAgents: number
offlineAgents: number
teamLoads: DashboardTeamLoad[]
}
aiStats: {
enabledAiAgents: number
enabledChannels: number
todayKnowledgeRetrieves: number
todayKnowledgeRetrieveFailCount: number
todayKnowledgeRetrieveFailRate: number
todayAgentRunFailCount: number
todayAiHandoffCount: number
}
alerts: DashboardAlert[]
quickLinks: DashboardQuickLink[]
}
export function fetchDashboardOverview(range: DashboardRange) {
return request<DashboardOverview>(`/api/dashboard/dashboard/overview?range=${range}`)
}

595
web/lib/api/im.ts Normal file
View File

@@ -0,0 +1,595 @@
import { request } from "@/lib/api/client"
import { translateCurrentMessage } from "@/i18n/messages"
import { readSupportChatRuntimeConfig } from "@/lib/sdk/runtime-config"
import { generateUUID, parseApiTimestamp } from "@/lib/utils"
export type Paging = {
page: number
limit: number
total: number
}
export type PageResult<T> = {
results?: T[] | null
page: Paging
cursor?: string
hasMore?: boolean
}
export type ImConversationTag = {
id: number
name: string
color: string
}
export type ImConversationParticipant = {
id: number
participantType: string
participantId: number
externalParticipantId?: string
joinedAt?: string
leftAt?: string
status: number
}
export interface SubmitSatisfactionParams {
conversationId: number
score: number
feedback?: string
}
export interface ConversationSatisfactionResponse {
id: number
conversationId: number
score: number
feedback: string
createdAt: string
}
export type ImConversation = {
id: number
channelId: number
customerName: string
subject?: string
status: number
serviceMode: number
priority: number
currentAssigneeId: number
currentAssigneeName?: string
currentTeamId?: number
lastMessageId: number
lastMessageAt?: string
lastActiveAt?: string
lastMessageSummary?: string
customerUnreadCount: number
agentUnreadCount: number
customerLastReadMessageId: number
customerLastReadAt?: string
agentLastReadMessageId: number
agentLastReadAt?: string
closedAt?: string
tags?: ImConversationTag[]
participants?: ImConversationParticipant[]
satisfaction?: ConversationSatisfactionResponse | null
}
export type ImConversationDetail = ImConversation
export type ConversationResponse = ImConversation
export type ImMessage = {
id: number
conversationId: number
workflowRunId?: number
clientMsgId?: string
senderType: string
senderId: number
senderName?: string
senderAvatar?: string
messageType: string
content: string
payload?: string
sendStatus: number
sentAt?: string
deliveredAt?: string
readAt?: string
customerRead: boolean
customerReadAt?: string
agentRead: boolean
agentReadAt?: string
recalledAt?: string
quotedMessageId?: number
}
export type ImAsset = {
id: number
assetId: string
provider: string
storageKey: string
filename: string
fileSize: number
mimeType: string
status: number
url: string
createdAt: string
updatedAt: string
createUserId: number
createUserName: string
updateUserId: number
updateUserName: string
}
export type WidgetConfigResponse = {
channelId?: string
channelType?: string
userToken?: string
title?: string
subtitle?: string
themeColor?: string
position?: "left" | "right"
width?: string
promptStarters?: string[]
}
export type ImWidgetConfig = WidgetConfigResponse
export type ImCustomerSessionCustomer = {
id: number
name: string
}
export type ImCustomerSessionExchangeResponse = {
customerSessionToken: string
expiresAt: string
identityKey: string
customer: ImCustomerSessionCustomer
}
export type ImCustomerSession = ImCustomerSessionExchangeResponse & {
channelId: string
baseUrl: string
}
const GUEST_STORAGE_KEY = "cs_ai_agent_im_guest_id"
const CUSTOMER_SESSION_STORAGE_KEY = "cs_ai_agent_customer_session"
const CUSTOMER_SESSION_TOKEN_HEADER = "X-Customer-Session-Token"
const CUSTOMER_SESSION_EXPIRES_HEADER = "X-Customer-Session-Expires-At"
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() || ""
const OPEN_IM_CHANNEL_ID =
process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() || ""
let entryIdentityExchangeKey = ""
function buildGuestId() {
return `guest_${generateUUID()}`
}
export function getGuestId() {
if (typeof window === "undefined") {
return ""
}
const existing = window.localStorage.getItem(GUEST_STORAGE_KEY)?.trim()
if (existing) {
return existing
}
const guestId = buildGuestId()
window.localStorage.setItem(GUEST_STORAGE_KEY, guestId)
return guestId
}
function getRuntimeImConfig() {
const widgetConfig = readSupportChatRuntimeConfig()
const baseUrl = (widgetConfig.apiBaseUrl || widgetConfig.baseUrl || API_BASE_URL)
.trim()
.replace(/\/$/, "")
return {
baseUrl,
channelId: widgetConfig.channelId || OPEN_IM_CHANNEL_ID,
externalId: (widgetConfig.externalId || "").trim(),
externalIdSignature: (widgetConfig.externalIdSignature || "").trim(),
externalIdSignedAt: Number(widgetConfig.externalIdSignedAt) || 0,
externalName: (widgetConfig.externalName || "").trim(),
userToken: (widgetConfig.userToken || "").trim(),
requireUserToken: widgetConfig.requireUserToken === true,
}
}
type RuntimeImConfig = ReturnType<typeof getRuntimeImConfig>
function identityContextKey(config: RuntimeImConfig) {
return JSON.stringify([
config.baseUrl,
config.channelId,
config.externalId,
config.userToken,
config.externalIdSignature,
config.externalIdSignedAt,
config.requireUserToken,
])
}
function requireEntryToken(config: RuntimeImConfig) {
if (config.requireUserToken && !config.userToken) {
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
}
}
function matchesSessionIdentity(session: ImCustomerSession, config: RuntimeImConfig) {
if (typeof session.identityKey !== "string") return false
if (session.baseUrl !== config.baseUrl || session.channelId !== config.channelId) return false
if (config.userToken || config.requireUserToken) {
return (
!!config.userToken &&
(config.externalId
? session.identityKey === `user:${config.externalId}`
: session.identityKey.startsWith("user:"))
)
}
const externalId = config.externalId || getGuestId()
return (
session.identityKey === `guest:${externalId}` ||
(!!config.externalIdSignature && session.identityKey === `user:${externalId}`)
)
}
function usableCustomerSession(session: ImCustomerSession | null, config: RuntimeImConfig) {
if (!session || !isCustomerSessionValid(session) || !matchesSessionIdentity(session, config)) return false
// A prior iframe's session, especially a prior guest, cannot replace this
// entry's JWT verification. Signed HMAC entries likewise re-exchange first.
if ((config.userToken || config.externalIdSignature) && entryIdentityExchangeKey !== identityContextKey(config)) {
return false
}
return true
}
function sessionTokenClaims(token: string): { identityKey?: string; channelCode?: string } | null {
try {
const payload = token.split(".")[1]
if (!payload) return null
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/")
const bytes = Uint8Array.from(
atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "=")),
(c) => c.charCodeAt(0)
)
return JSON.parse(new TextDecoder().decode(bytes))
} catch {
return null
}
}
function parseExpiresAt(value: string) {
return parseApiTimestamp(value)
}
function isCustomerSessionValid(
session: ImCustomerSession | null,
channelId?: string,
identityKey?: string
) {
if (!session?.customerSessionToken || !session.expiresAt) {
return false
}
if (channelId && session.channelId !== channelId) {
return false
}
if (identityKey && session.identityKey !== identityKey) {
return false
}
return parseExpiresAt(session.expiresAt) > Date.now() + 5000
}
export function readCustomerSession(): ImCustomerSession | null {
if (typeof window === "undefined") {
return null
}
const raw = window.sessionStorage.getItem(CUSTOMER_SESSION_STORAGE_KEY)
if (!raw) {
return null
}
try {
return JSON.parse(raw) as ImCustomerSession
} catch {
window.sessionStorage.removeItem(CUSTOMER_SESSION_STORAGE_KEY)
return null
}
}
function writeCustomerSession(session: ImCustomerSession) {
if (typeof window === "undefined") {
return
}
window.sessionStorage.setItem(CUSTOMER_SESSION_STORAGE_KEY, JSON.stringify(session))
}
export function getCustomerSessionToken() {
const config = getRuntimeImConfig()
const session = readCustomerSession()
return usableCustomerSession(session, config)
? session?.customerSessionToken ?? ""
: ""
}
export function applyCustomerSessionRefresh(payload?: {
customerSessionToken?: string
expiresAt?: string
}) {
const token = payload?.customerSessionToken?.trim()
const expiresAt = payload?.expiresAt?.trim()
if (!token || !expiresAt) {
return
}
const current = readCustomerSession()
const config = getRuntimeImConfig()
if (!current || !usableCustomerSession(current, config)) return
// Decode only for cache consistency, never as proof of authenticity. The
// server still verifies signatures. A late old-account websocket/HTTP refresh
// must not overwrite the current account's stored customer session.
const claims = sessionTokenClaims(token)
if (claims?.identityKey !== current.identityKey || claims?.channelCode !== current.channelId) return
writeCustomerSession({
...current,
customerSessionToken: token,
expiresAt,
})
}
function applyCustomerSessionHeaders(response: Response) {
applyCustomerSessionRefresh({
customerSessionToken: response.headers.get(CUSTOMER_SESSION_TOKEN_HEADER) ?? "",
expiresAt: response.headers.get(CUSTOMER_SESSION_EXPIRES_HEADER) ?? "",
})
}
function createChannelHeaders() {
const config = getRuntimeImConfig()
return {
"X-Channel-Id": config.channelId,
}
}
function createExchangeHeaders() {
const config = getRuntimeImConfig()
requireEntryToken(config)
const headers: Record<string, string> = {
"X-Channel-Id": config.channelId,
}
if (config.userToken) {
headers.Authorization = `Bearer ${config.userToken}`
} else {
headers["X-External-Id"] = config.externalId || getGuestId()
// Only meaningful together with a host-supplied externalId: the browser-local
// guest id cannot be signed by the host. Without a valid signature the
// server treats the visitor as anonymous.
if (config.externalId && config.externalIdSignature && config.externalIdSignedAt) {
headers["X-External-Id-Signature"] = config.externalIdSignature
headers["X-External-Id-Signed-At"] = String(config.externalIdSignedAt)
}
if (config.externalName) {
headers["X-External-Name"] = encodeURIComponent(config.externalName)
}
}
return {
...headers,
}
}
function createImHeaders(explicitToken?: string) {
const sessionToken = explicitToken || getCustomerSessionToken()
if (!sessionToken) {
throw new Error(translateCurrentMessage("api.customerSessionNotReady"))
}
return {
...createChannelHeaders(),
Authorization: `Bearer ${sessionToken}`,
...(explicitToken ? { "X-Customer-Session-Token": explicitToken } : {}),
}
}
function createRequestOptions(
init?: RequestInit,
explicitToken?: string
): RequestInit & {
baseUrl?: string
skipAuth?: boolean
onResponse?: (response: Response) => void
} {
const config = getRuntimeImConfig()
requireEntryToken(config)
const contextKey = identityContextKey(config)
return {
...init,
skipAuth: true,
headers: {
...createImHeaders(explicitToken),
...(init?.headers as Record<string, string> | undefined),
},
onResponse: (response) => {
if (contextKey === identityContextKey(getRuntimeImConfig())) {
applyCustomerSessionHeaders(response)
}
},
baseUrl: config.baseUrl,
}
}
function toQueryString(query?: Record<string, string | number | undefined>) {
if (!query) {
return ""
}
const params = new URLSearchParams()
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === "") {
return
}
params.set(key, String(value))
})
const output = params.toString()
return output ? `?${output}` : ""
}
export async function exchangeCustomerSession() {
const config = getRuntimeImConfig()
requireEntryToken(config)
const contextKey = identityContextKey(config)
const result = await request<ImCustomerSessionExchangeResponse>(
"/api/customer/session_exchange",
{
method: "POST",
skipAuth: true,
baseUrl: config.baseUrl,
headers: createExchangeHeaders(),
}
)
if (contextKey !== identityContextKey(getRuntimeImConfig())) {
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
}
const session: ImCustomerSession = {
...result,
channelId: config.channelId,
baseUrl: config.baseUrl,
}
if (!isCustomerSessionValid(session) || !matchesSessionIdentity(session, config)) {
throw new Error(translateCurrentMessage("api.supportAuthenticationRequired"))
}
writeCustomerSession(session)
entryIdentityExchangeKey = contextKey
return session
}
export async function ensureCustomerSession() {
const config = getRuntimeImConfig()
requireEntryToken(config)
const cached = readCustomerSession()
if (usableCustomerSession(cached, config)) return cached!
return exchangeCustomerSession()
}
export function fetchImConversationDetail(id: number) {
return request<ImConversationDetail>(`/api/conversation/${id}`, {
...createRequestOptions(),
})
}
export function fetchImMessages(
query?: Record<string, string | number | undefined>
) {
return request<PageResult<ImMessage>>(
`/api/message/list${toQueryString(query)}`,
createRequestOptions()
)
}
export function createOrMatchImConversation() {
return request<ImConversation>("/api/conversation/create_or_match", {
...createRequestOptions({ method: "POST" }),
})
}
// Starts a new request instead of rejoining the unfinished one. The server caps
// how many open requests one visitor may have and answers with a localized
// message when the cap is reached.
export function createImConversation(subject?: string) {
return request<ImConversation>("/api/conversation/create", {
...createRequestOptions({ method: "POST" }),
body: JSON.stringify({ subject: subject?.trim() ?? "" }),
})
}
// The visitor's own requests, newest activity first. Scoping comes from the
// external identity the request already carries.
export function fetchImConversationList(
query?: Record<string, string | number | undefined>
) {
return request<PageResult<ImConversation>>(
`/api/conversation/list${toQueryString(query)}`,
createRequestOptions()
)
}
export function fetchImWidgetConfig() {
return request<ImWidgetConfig>(
`/api/channel/config${toQueryString({
channelId: getRuntimeImConfig().channelId,
})}`,
{
skipAuth: true,
baseUrl: getRuntimeImConfig().baseUrl,
headers: createChannelHeaders(),
}
)
}
export function closeImConversation(conversationId: number) {
return request<void>("/api/conversation/close", {
...createRequestOptions({
method: "POST",
body: JSON.stringify({ conversationId }),
}),
})
}
export function sendImMessage(payload: {
conversationId: number
messageType: string
content: string
payload?: string
clientMsgId?: string
}) {
return request<ImMessage>("/api/message/send", {
...createRequestOptions({
method: "POST",
body: JSON.stringify(payload),
}),
})
}
export function markImMessageRead(conversationId: number, messageId = 0) {
return request<void>("/api/message/read", {
...createRequestOptions({
method: "POST",
body: JSON.stringify({ conversationId, messageId }),
}),
})
}
export function uploadImImage(conversationId: number, file: File) {
const formData = new FormData()
formData.set("conversationId", String(conversationId))
formData.set("file", file)
return request<ImAsset>("/api/message/upload_image", {
...createRequestOptions({
method: "POST",
body: formData,
}),
})
}
export function uploadImAttachment(conversationId: number, file: File) {
const formData = new FormData()
formData.set("conversationId", String(conversationId))
formData.set("file", file)
return request<ImAsset>("/api/message/upload_attachment", {
...createRequestOptions({
method: "POST",
body: formData,
}),
})
}
export async function submitConversationSatisfaction(
params: SubmitSatisfactionParams,
token?: string
): Promise<ConversationSatisfactionResponse> {
return request<ConversationSatisfactionResponse>(
"/api/conversation/satisfaction",
createRequestOptions(
{
method: "POST",
body: JSON.stringify(params),
},
token
)
)
}

View File

@@ -0,0 +1,82 @@
import { readSession } from "@/lib/auth"
import { request } from "@/lib/api/client"
import { createWebSocketBaseUrl } from "@/lib/api/websocket"
import type { PageResult } from "@/lib/api/admin"
import { translateCurrentMessage } from "@/i18n/messages"
export type NotificationReadStatus = "all" | "unread" | "read"
export type NotificationItem = {
id: number
recipientUserId: number
title: string
content: string
notificationType: string
bizType: string
bizId: number
actionUrl: string
readAt?: string
createdAt?: string
}
export type NotificationUnreadCount = {
unreadCount: number
}
export type NotificationListQuery = {
page?: number
limit?: number
readStatus?: NotificationReadStatus
type?: string
}
function toQueryString(query?: Record<string, string | number | undefined>) {
if (!query) {
return ""
}
const params = new URLSearchParams()
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === "") {
return
}
params.set(key, String(value))
})
const output = params.toString()
return output ? `?${output}` : ""
}
export function fetchNotifications(query?: NotificationListQuery) {
return request<PageResult<NotificationItem>>(
`/api/dashboard/notification/list${toQueryString(query)}`
)
}
export function fetchNotificationUnreadCount() {
return request<NotificationUnreadCount>("/api/dashboard/notification/unread_count")
}
export function markNotificationRead(id: number) {
return request<void>("/api/dashboard/notification/mark_read", {
method: "POST",
body: JSON.stringify({ id }),
})
}
export function markAllNotificationsRead() {
return request<void>("/api/dashboard/notification/mark_all_read", {
method: "POST",
})
}
export function createNotificationWebSocketUrl() {
const session = readSession()
if (!session?.accessToken) {
throw new Error(translateCurrentMessage("api.authExpired"))
}
const params = new URLSearchParams({
accessToken: session.accessToken,
})
return `${createWebSocketBaseUrl()}/api/ws/dashboard/notification?${params.toString()}`
}

View File

@@ -0,0 +1,31 @@
import { request } from "@/lib/api/client"
export type OrganizationItem = {
id: number
code: string
name: string
logo: string
plan: string
status: number
role?: string
isActive: boolean
createdAt: string
}
export type UserOrganizationListResponse = {
currentOrganizationId: number
organizations: OrganizationItem[]
}
export async function listMyOrganizations() {
return request<UserOrganizationListResponse>("/api/dashboard/organization/my_list", {
method: "GET",
})
}
export async function switchOrganization(organizationId: number) {
return request<OrganizationItem>("/api/dashboard/organization/switch", {
method: "POST",
body: JSON.stringify({ organizationId }),
})
}

View File

@@ -0,0 +1,134 @@
import { request } from "@/lib/api/client"
export type SetupStepStatus = "ok" | "warning" | "error"
export type SetupQuickFixAction =
| "quick_setup_all"
| "ensure_team_and_schedule"
| "enable_my_operator_profile"
| "fix_ai_agent_handoff"
| "ensure_default_channel"
export type SetupIssueItem = {
code: string
severity: "warning" | "error"
entity_type: "team" | "schedule" | "operator" | "ai_agent" | "channel" | "system"
entity_id: number
entity_name: string
quick_fix_action?: SetupQuickFixAction
}
export type SetupStepItem = {
key: "teams" | "schedules" | "operators" | "ai_agents" | "channels" | "end_to_end"
step_number: number
status: SetupStepStatus
summary_code: string
total_count: number
ready_count: number
issues: SetupIssueItem[]
quick_fix_actions: SetupQuickFixAction[]
manage_path: string
}
export type CurrentOperatorDiagnostic = {
user_id: number
username: string
nickname: string
has_profile: boolean
profile_status_ok: boolean
team_id: number
team_name: string
team_active: boolean
team_has_schedule_now: boolean
service_status: number
auto_assign_enabled: boolean
active_conversation_count: number
max_concurrent_count: number
can_receive_now: boolean
blocker_codes: string[]
}
export type SetupChannelBrief = {
id: number
name: string
channel_type: string
channel_id: string
ai_agent_id: number
rollout_percent: number
status: number
}
export type SetupReadinessResponse = {
overall_status: SetupStepStatus
ready_steps: number
total_steps: number
can_receive_requests_now: boolean
steps: SetupStepItem[]
current_operator: CurrentOperatorDiagnostic
channels: SetupChannelBrief[]
}
export type SimulatedRouteHop = {
hop_key: "channel" | "ai_agent" | "teams" | "schedules" | "operators"
status: SetupStepStatus
reason_code: string
entity_id?: number
entity_name?: string
quick_fix_action?: SetupQuickFixAction
}
export type SimulatedOperatorCandidate = {
user_id: number
display_name: string
team_id: number
team_name: string
eligible: boolean
skip_reason_code: string
active_count: number
max_concurrent_count: number
service_status: number
auto_assign_enabled: boolean
}
export type SetupSimulateRouteResponse = {
channel_id: number
channel_name: string
channel_type: string
will_reach_operator: boolean
final_outcome_code: string
matched_operator?: SimulatedOperatorCandidate
hops: SimulatedRouteHop[]
candidate_operators: SimulatedOperatorCandidate[]
}
export type SetupQuickFixResponse = {
applied_actions: string[]
readiness: SetupReadinessResponse
}
export function fetchSetupReadiness() {
return request<SetupReadinessResponse>("/api/dashboard/setup/readiness")
}
export function simulateSetupRoute(payload: {
channel_id?: number
bypass_ai?: boolean
}) {
return request<SetupSimulateRouteResponse>(
"/api/dashboard/setup/simulate_route",
{
method: "POST",
body: JSON.stringify(payload),
},
)
}
export function applySetupQuickFix(payload: {
action: SetupQuickFixAction
entity_id?: number
}) {
return request<SetupQuickFixResponse>("/api/dashboard/setup/quick_fix", {
method: "POST",
body: JSON.stringify(payload),
})
}

View File

@@ -0,0 +1,176 @@
import { type PageResult } from "@/lib/api/admin"
import { request } from "@/lib/api/client"
import type { PageData } from "@/lib/api/types"
export type Category = {
id: number
name: string
slug: string
description: string
parentId?: number
sortNo: number
status: number
}
export type Post = {
id: number
categoryId: number
categoryName: string
userId: number
userName: string
userType: string
title: string
contentType: string
content: string
tags: string[]
status: string
acceptedCommentId: number
commentCount: number
reactionCount: number
viewCount: number
createdAt: string
updatedAt: string
}
export type Comment = {
id: number
postId: number
parentId: number
authorType: string
authorId: number
authorName: string
contentType: string
content: string
status: string
reactionCount: number
replyCount: number
reportCount: number
isAccepted: boolean
replies: Comment[]
createdAt: string
updatedAt: string
}
export type PostDetail = {
comments: Comment[]
post: Post
}
export type ReactionTargetType = "post" | "comment"
export type ReactionType = "like"
export function categoryHref(slug: string) {
return `/support/community/categories/${encodeURIComponent(slug)}`
}
export function newPostHref() {
return "/support/community/posts/new"
}
export function postHref(id: number) {
return `/support/community/posts/${id}`
}
export function postsHref() {
return "/support/community/posts"
}
export function fetchCategories() {
return request<Category[]>("/api/support/community/categories/list", { skipAuth: true })
}
export function fetchPosts(query?: Record<string, string | number | undefined>) {
return request<PageData<Post>>(`/api/support/community/posts/list${toQueryString(query)}`, {
skipAuth: true,
})
}
export function fetchPost(id: number) {
return request<PostDetail>(`/api/support/community/posts/${id}`, { skipAuth: true })
}
export function createPost(payload: {
categoryId: number
content: string
contentType: string
tags: string[]
title: string
}) {
return request<Post>("/api/support/community/posts/create", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function fetchComments(query: {
limit?: number
page?: number
parentId?: number
postId: number
sort?: string
}) {
return request<PageResult<Comment>>(`/api/support/community/comments/list${toQueryString(query)}`, {
skipAuth: true,
})
}
export function createComment(payload: {
content: string
contentType: string
parentId?: number
postId: number
}) {
return request<Comment>("/api/support/community/comments/create", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function updateComment(payload: { content: string; contentType: string; id: number }) {
return request<void>("/api/support/community/comments/update", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function deleteComment(id: number) {
return request<void>("/api/support/community/comments/delete", {
method: "POST",
body: JSON.stringify({ id }),
})
}
export function reportComment(id: number, reason = "") {
return request<void>("/api/support/community/comments/report", {
method: "POST",
body: JSON.stringify({ id, reason }),
})
}
export function acceptComment(postId: number, commentId: number) {
return request<void>("/api/support/community/posts/accept_comment", {
method: "POST",
body: JSON.stringify({ postId, commentId }),
})
}
export function toggleReaction(payload: { reactionType?: ReactionType; targetId: number; targetType: ReactionTargetType }) {
return request<void>("/api/support/community/reactions/toggle", {
method: "POST",
body: JSON.stringify({ reactionType: payload.reactionType || "like", targetId: payload.targetId, targetType: payload.targetType }),
})
}
function toQueryString(query?: Record<string, string | number | undefined>) {
if (!query) {
return ""
}
const params = new URLSearchParams()
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== "" && value !== "all") {
params.set(key, String(value))
}
})
const raw = params.toString()
return raw ? `?${raw}` : ""
}

View File

@@ -0,0 +1,19 @@
import { request } from "@/lib/api/client"
export type SupportNavigationMenuItem = {
id: string
title: string
url: string
openInNewWindow: boolean
visible: boolean
sortNo: number
children?: SupportNavigationMenuItem[]
}
export type PublicSupportConfig = {
navigationMenu: SupportNavigationMenuItem[]
}
export function fetchSupportConfig() {
return request<PublicSupportConfig>("/api/support/config", { skipAuth: true })
}

90
web/lib/api/support.ts Normal file
View File

@@ -0,0 +1,90 @@
import { type PageResult } from "@/lib/api/admin"
import { loginWithPassword } from "@/lib/api/auth"
import { request } from "@/lib/api/client"
import { type AuthSession, writeSession } from "@/lib/auth"
export type DocPage = {
id: number
parentId: number
title: string
slug: string
summary: string
contentType: string
content: string
coverUrl: string
tags: string[]
status: string
sortNo: number
viewCount: number
helpfulCount: number
unhelpfulCount: number
publishedAt: string
createdAt: string
updatedAt: string
docPath?: string
}
export type DocNavigationNode = Pick<DocPage, "id" | "parentId" | "title" | "slug" | "sortNo"> & {
children: DocNavigationNode[]
}
export type SupportUser = {
id: number
name: string
email: string
userType: string
}
export async function loginSupportCustomer(payload: { email: string; password: string }) {
return loginWithPassword({ username: payload.email, password: payload.password })
}
export async function registerSupportCustomer(payload: { name: string; email: string; password: string }) {
const session = await request<AuthSession>("/api/support/auth/register", {
method: "POST",
skipAuth: true,
body: JSON.stringify(payload),
})
writeSession(session)
return session
}
export function fetchSupportMe() {
return request<SupportUser>("/api/support/me")
}
export function fetchDocPages(query?: Record<string, string | number | undefined>) {
return request<PageResult<DocPage>>(`/api/support/doc-page/list${toQueryString(query)}`, {
skipAuth: true,
})
}
export function fetchDocNavigation() {
return request<DocNavigationNode[]>("/api/support/doc-page/navigation", { skipAuth: true })
}
export function fetchDocPage(id: number) {
return request<DocPage>(`/api/support/doc-page/${id}`, { skipAuth: true })
}
export function submitDocPageFeedback(id: number, helpful: boolean) {
return request<void>("/api/support/doc-page/feedback", {
method: "POST",
skipAuth: true,
body: JSON.stringify({ id, helpful }),
})
}
function toQueryString(query?: Record<string, string | number | undefined>) {
if (!query) {
return ""
}
const params = new URLSearchParams()
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== "" && value !== "all") {
params.set(key, String(value))
}
})
const raw = params.toString()
return raw ? `?${raw}` : ""
}

239
web/lib/api/ticket.ts Normal file
View File

@@ -0,0 +1,239 @@
import { request } from "@/lib/api/client"
import type { Tag } from "@/lib/api/admin"
export type Paging = {
page: number
limit: number
total: number
}
export type PageResult<T> = {
results: T[]
page: Paging
}
export type TicketStatus = "pending" | "in_progress" | "done"
export type TicketSource = "manual" | "conversation"
export type TicketCustomer = {
id: number
name: string
companyId?: number
company?: {
id: number
name: string
code?: string
remark?: string
createdAt?: string
updatedAt?: string
}
primaryMobile?: string
primaryEmail?: string
}
export type TicketProgress = {
id: number
ticketId: number
content: string
authorId: number
authorName?: string
createdAt?: string
}
export type TicketItem = {
id: number
ticketNo: string
title: string
description: string
source: TicketSource
channel: string
customerId: number
conversationId: number
tags?: Tag[]
status: TicketStatus
currentAssigneeId: number
currentAssigneeName?: string
createdBy: number
createdByName?: string
handledAt?: string
createdAt?: string
updatedAt?: string
customer?: TicketCustomer
}
export type TicketDetail = {
ticket: TicketItem
progresses?: TicketProgress[]
}
export type TicketSummary = {
all: number
pending: number
inProgress: number
done: number
unassigned: number
mine: number
stale: number
}
export type TicketSavedView = {
id: number
name: string
filters?: Record<string, unknown>
sortNo: number
}
export type TicketListQuery = {
page?: number
limit?: number
keyword?: string
status?: TicketStatus
tagId?: number
currentAssigneeId?: number
customerId?: number
conversationId?: number
source?: TicketSource
channel?: string
mine?: number | boolean
unassigned?: number | boolean
staleHours?: number
}
export type CreateTicketPayload = {
title: string
description: string
source?: TicketSource
channel?: string
customerId?: number
conversationId?: number
tagIds?: number[]
currentAssigneeId?: number
}
export type CreateTicketFromConversationPayload = {
conversationId: number
title: string
description: string
tagIds?: number[]
currentAssigneeId?: number
}
export type UpdateTicketPayload = {
ticketId: number
title: string
description: string
tagIds?: number[]
currentAssigneeId?: number
}
function toQueryString(query?: Record<string, string | number | boolean | undefined>) {
if (!query) {
return ""
}
const params = new URLSearchParams()
Object.entries(query).forEach(([key, value]) => {
if (value === undefined || value === "") {
return
}
params.set(key, String(value))
})
const output = params.toString()
return output ? `?${output}` : ""
}
export function fetchTickets(query?: TicketListQuery) {
return request<PageResult<TicketItem>>(`/api/dashboard/ticket/list${toQueryString(query)}`)
}
export function fetchTicketSummary(query?: { staleHours?: number }) {
return request<TicketSummary>(`/api/dashboard/ticket/summary${toQueryString(query)}`)
}
export function fetchTicketDetail(id: number) {
return request<TicketDetail>(`/api/dashboard/ticket/${id}`)
}
export function createTicket(payload: CreateTicketPayload) {
return request<TicketItem>("/api/dashboard/ticket/create", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function createTicketFromConversation(payload: CreateTicketFromConversationPayload) {
return request<TicketItem>("/api/dashboard/ticket/create_from_conversation", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function updateTicket(payload: UpdateTicketPayload) {
return request<void>("/api/dashboard/ticket/update", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function linkTicketToCustomer(payload: {
ticketId: number
customerId: number
}) {
return request<void>("/api/dashboard/ticket/link_customer", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function assignTicket(payload: {
ticketId: number
toUserId: number
reason?: string
}) {
return request<void>("/api/dashboard/ticket/assign", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function changeTicketStatus(payload: {
ticketId: number
status: TicketStatus
}) {
return request<void>("/api/dashboard/ticket/change_status", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function createTicketProgress(payload: {
ticketId: number
content: string
}) {
return request<TicketProgress>("/api/dashboard/ticket/progress/create", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function fetchTicketViews() {
return request<TicketSavedView[]>("/api/dashboard/ticket/view_list")
}
export function saveTicketView(payload: {
id?: number
name: string
filters: Record<string, unknown>
}) {
return request<TicketSavedView>("/api/dashboard/ticket/save_view", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function deleteTicketView(id: number) {
return request<void>("/api/dashboard/ticket/delete_view", {
method: "POST",
body: JSON.stringify({ id }),
})
}

5
web/lib/api/types.ts Normal file
View File

@@ -0,0 +1,5 @@
export type PageData<T> = {
cursor?: string
hasMore: boolean
results: T[]
}

16
web/lib/api/websocket.ts Normal file
View File

@@ -0,0 +1,16 @@
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() ||
(process.env.NODE_ENV === "development" ? "http://127.0.0.1:8083" : "")
export function createWebSocketBaseUrl() {
if (API_BASE_URL) {
return API_BASE_URL.replace(/^http/, "ws").replace(/\/$/, "")
}
if (typeof window === "undefined") {
return ""
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
return `${protocol}//${window.location.host}`
}

68
web/lib/auth.ts Normal file
View File

@@ -0,0 +1,68 @@
export type AuthUser = {
id: number
username: string
nickname: string
avatar: string
email?: string
userType: string
status: number
roles: string[]
}
export type AuthSession = {
accessToken: string
expiresAt?: string
user: AuthUser
permissions: string[]
roles: string[]
}
const SESSION_STORAGE_KEY = "agent-desk-session"
export const AUTH_SESSION_EXPIRED_EVENT = "agent-desk-auth-expired"
export const AUTH_SESSION_CHANGED_EVENT = "agent-desk-auth-changed"
function hasWindow() {
return typeof window !== "undefined"
}
export function readSession(): AuthSession | null {
if (!hasWindow()) {
return null
}
const raw = window.localStorage.getItem(SESSION_STORAGE_KEY)
if (!raw) {
return null
}
try {
return JSON.parse(raw) as AuthSession
} catch {
window.localStorage.removeItem(SESSION_STORAGE_KEY)
return null
}
}
export function writeSession(session: AuthSession) {
if (!hasWindow()) {
return
}
window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session))
window.dispatchEvent(new Event(AUTH_SESSION_CHANGED_EVENT))
}
export function clearSession() {
if (!hasWindow()) {
return
}
window.localStorage.removeItem(SESSION_STORAGE_KEY)
window.dispatchEvent(new Event(AUTH_SESSION_CHANGED_EVENT))
}
export function expireSession() {
if (!hasWindow()) {
return
}
clearSession()
window.dispatchEvent(new Event(AUTH_SESSION_EXPIRED_EVENT))
}

15
web/lib/enums.ts Normal file
View File

@@ -0,0 +1,15 @@
export function getEnumLabel<T extends string | number>(
enumLabels: Record<T, string>,
value: T
): string {
return enumLabels[value] || String(value)
}
export function getEnumOptions<T extends string | number>(
enumLabels: Record<T, string>
): Array<{ value: T; label: string }> {
return Object.entries(enumLabels).map(([value, label]) => ({
value: value as T,
label: label as string,
}))
}

16
web/lib/fonts.ts Normal file
View File

@@ -0,0 +1,16 @@
import { IBM_Plex_Mono, Inter } from "next/font/google"
export const appSans = Inter({
variable: "--font-app-sans",
subsets: ["latin", "latin-ext", "cyrillic", "cyrillic-ext"],
display: "swap",
})
export const appMono = IBM_Plex_Mono({
variable: "--font-app-mono",
subsets: ["latin", "latin-ext", "cyrillic", "cyrillic-ext"],
weight: ["400", "500", "600"],
display: "swap",
})
export const appFontVariables = `${appSans.variable} ${appMono.variable}`

391
web/lib/generated/enums.ts Normal file
View File

@@ -0,0 +1,391 @@
// Code generated by cmd/enums/generator.go. DO NOT EDIT.
export enum AIAgentFallbackMode {
NoAnswer = 1,
SuggestRetry = 2,
Handoff = 3,
}
export const AIAgentFallbackModeLabels: Record<AIAgentFallbackMode, string> = {
[AIAgentFallbackMode.NoAnswer]: "直接说明知识不足",
[AIAgentFallbackMode.SuggestRetry]: "引导用户补充信息",
[AIAgentFallbackMode.Handoff]: "转人工客服",
}
export enum AIAgentHandoffMode {
WaitPool = 1,
DefaultTeamPool = 2,
AIHoldAndNotify = 3,
}
export const AIAgentHandoffModeLabels: Record<AIAgentHandoffMode, string> = {
[AIAgentHandoffMode.WaitPool]: "进入待接入池",
[AIAgentHandoffMode.DefaultTeamPool]: "进入默认客服组待接入池",
[AIAgentHandoffMode.AIHoldAndNotify]: "AI继续接待并提醒人工",
}
export enum AIModelType {
LLM = "llm",
Embedding = "embedding",
Rerank = "rerank",
}
export const AIModelTypeLabels: Record<AIModelType, string> = {
[AIModelType.LLM]: "大语言模型",
[AIModelType.Embedding]: "向量模型",
[AIModelType.Rerank]: "重排序模型",
}
export enum AIProvider {
OpenAI = "openai",
}
export const AIProviderLabels: Record<AIProvider, string> = {
[AIProvider.OpenAI]: "OpenAI",
}
export enum AssetProvider {
Local = "local",
OSS = "oss",
}
export const AssetProviderLabels: Record<AssetProvider, string> = {
[AssetProvider.Local]: "本地存储",
[AssetProvider.OSS]: "对象存储",
}
export enum AssetStatus {
Pending = 1,
Success = 2,
Failed = 3,
Deleted = 4,
}
export const AssetStatusLabels: Record<AssetStatus, string> = {
[AssetStatus.Pending]: "处理中",
[AssetStatus.Success]: "成功",
[AssetStatus.Failed]: "失败",
[AssetStatus.Deleted]: "已删除",
}
export enum ContactType {
Mobile = "mobile",
Email = "email",
Other = "other",
}
export const ContactTypeLabels: Record<ContactType, string> = {
[ContactType.Mobile]: "手机号",
[ContactType.Email]: "邮箱",
[ContactType.Other]: "其他",
}
export enum ExternalSource {
Guest = "guest",
User = "user",
Telegram = "telegram",
ZaloOA = "zalo_oa",
Slack = "slack",
Discord = "discord",
Email = "email",
}
export const ExternalSourceLabels: Record<ExternalSource, string> = {
[ExternalSource.Guest]: "访客",
[ExternalSource.User]: "用户",
[ExternalSource.Telegram]: "Telegram",
[ExternalSource.ZaloOA]: "Zalo OA",
[ExternalSource.Slack]: "Slack",
[ExternalSource.Discord]: "Discord",
[ExternalSource.Email]: "Email",
}
export enum Gender {
Unknown = 0,
Male = 1,
Female = 2,
}
export const GenderLabels: Record<Gender, string> = {
[Gender.Unknown]: "未知",
[Gender.Male]: "男",
[Gender.Female]: "女",
}
export enum IMAssignmentStatus {
Active = 0,
Inactive = 1,
}
export const IMAssignmentStatusLabels: Record<IMAssignmentStatus, string> = {
[IMAssignmentStatus.Active]: "进行中",
[IMAssignmentStatus.Inactive]: "已结束",
}
export enum IMAssignmentType {
Assign = "assign",
Transfer = "transfer",
}
export const IMAssignmentTypeLabels: Record<IMAssignmentType, string> = {
[IMAssignmentType.Assign]: "分配",
[IMAssignmentType.Transfer]: "转接",
}
export enum IMConversationServiceMode {
AIOnly = 1,
HumanOnly = 2,
AIFirst = 3,
}
export const IMConversationServiceModeLabels: Record<IMConversationServiceMode, string> = {
[IMConversationServiceMode.AIOnly]: "仅AI",
[IMConversationServiceMode.HumanOnly]: "仅人工",
[IMConversationServiceMode.AIFirst]: "AI优先",
}
export enum IMConversationStatus {
AIServing = 1,
Pending = 2,
Active = 3,
Closed = 4,
}
export const IMConversationStatusLabels: Record<IMConversationStatus, string> = {
[IMConversationStatus.AIServing]: "AI接待中",
[IMConversationStatus.Pending]: "待接入",
[IMConversationStatus.Active]: "处理中",
[IMConversationStatus.Closed]: "已关闭",
}
export enum IMEventType {
Create = "create",
Assign = "assign",
Transfer = "transfer",
Close = "close",
MessageSend = "message_send",
MessageRecall = "message_recall",
}
export const IMEventTypeLabels: Record<IMEventType, string> = {
[IMEventType.Create]: "创建会话",
[IMEventType.Assign]: "分配会话",
[IMEventType.Transfer]: "转接会话",
[IMEventType.Close]: "关闭会话",
[IMEventType.MessageSend]: "发送消息",
[IMEventType.MessageRecall]: "撤回消息",
}
export enum IMMessageStatus {
Sending = 1,
Sent = 2,
Delivered = 3,
Read = 4,
Failed = 5,
Recalled = 6,
}
export const IMMessageStatusLabels: Record<IMMessageStatus, string> = {
[IMMessageStatus.Sending]: "发送中",
[IMMessageStatus.Sent]: "已发送",
[IMMessageStatus.Delivered]: "已送达",
[IMMessageStatus.Read]: "已读",
[IMMessageStatus.Failed]: "发送失败",
[IMMessageStatus.Recalled]: "已撤回",
}
export enum IMMessageType {
Text = "text",
Image = "image",
Attachment = "attachment",
HTML = "html",
}
export const IMMessageTypeLabels: Record<IMMessageType, string> = {
[IMMessageType.Text]: "文本",
[IMMessageType.Image]: "图片",
[IMMessageType.Attachment]: "附件",
[IMMessageType.HTML]: "富文本",
}
export enum IMParticipantType {
Customer = "customer",
Agent = "agent",
AI = "ai",
System = "system",
}
export const IMParticipantTypeLabels: Record<IMParticipantType, string> = {
[IMParticipantType.Customer]: "客户",
[IMParticipantType.Agent]: "客服",
[IMParticipantType.AI]: "AI",
[IMParticipantType.System]: "系统",
}
export enum IMSenderType {
Agent = "agent",
Customer = "customer",
AI = "ai",
System = "system",
}
export const IMSenderTypeLabels: Record<IMSenderType, string> = {
[IMSenderType.Agent]: "客服",
[IMSenderType.Customer]: "客户",
[IMSenderType.AI]: "AI",
[IMSenderType.System]: "系统",
}
export enum KnowledgeAnswerMode {
Strict = 1,
Assist = 2,
}
export const KnowledgeAnswerModeLabels: Record<KnowledgeAnswerMode, string> = {
[KnowledgeAnswerMode.Strict]: "严格模式",
[KnowledgeAnswerMode.Assist]: "辅助模式",
}
export enum KnowledgeAnswerStatus {
Normal = 1,
NoAnswer = 2,
Fallback = 3,
Blocked = 4,
}
export const KnowledgeAnswerStatusLabels: Record<KnowledgeAnswerStatus, string> = {
[KnowledgeAnswerStatus.Normal]: "正常",
[KnowledgeAnswerStatus.NoAnswer]: "无答案",
[KnowledgeAnswerStatus.Fallback]: "兜底回复",
[KnowledgeAnswerStatus.Blocked]: "已屏蔽",
}
export enum KnowledgeBaseType {
Document = "document",
FAQ = "faq",
}
export const KnowledgeBaseTypeLabels: Record<KnowledgeBaseType, string> = {
[KnowledgeBaseType.Document]: "文档知识库",
[KnowledgeBaseType.FAQ]: "FAQ知识库",
}
export enum KnowledgeChunkProvider {
Fixed = "fixed",
Structured = "structured",
FAQ = "faq",
Semantic = "semantic",
}
export const KnowledgeChunkProviderLabels: Record<KnowledgeChunkProvider, string> = {
[KnowledgeChunkProvider.Fixed]: "固定长度",
[KnowledgeChunkProvider.Structured]: "结构化分块",
[KnowledgeChunkProvider.FAQ]: "问答式分块",
[KnowledgeChunkProvider.Semantic]: "语义分块",
}
export enum KnowledgeChunkType {
Text = "text",
FAQ = "faq",
Table = "table",
Code = "code",
}
export const KnowledgeChunkTypeLabels: Record<KnowledgeChunkType, string> = {
[KnowledgeChunkType.Text]: "文本",
[KnowledgeChunkType.FAQ]: "问答",
[KnowledgeChunkType.Table]: "表格",
[KnowledgeChunkType.Code]: "代码",
}
export enum KnowledgeDocumentContentType {
HTML = "html",
Markdown = "markdown",
}
export const KnowledgeDocumentContentTypeLabels: Record<KnowledgeDocumentContentType, string> = {
[KnowledgeDocumentContentType.HTML]: "HTML",
[KnowledgeDocumentContentType.Markdown]: "Markdown",
}
export enum KnowledgeDocumentIndexStatus {
Pending = "pending",
Indexed = "indexed",
Failed = "failed",
}
export const KnowledgeDocumentIndexStatusLabels: Record<KnowledgeDocumentIndexStatus, string> = {
[KnowledgeDocumentIndexStatus.Pending]: "待索引",
[KnowledgeDocumentIndexStatus.Indexed]: "已索引",
[KnowledgeDocumentIndexStatus.Failed]: "索引失败",
}
export enum KnowledgeFeedbackType {
Like = 1,
Dislike = 2,
NotHelpful = 3,
WrongCitation = 4,
Other = 5,
}
export const KnowledgeFeedbackTypeLabels: Record<KnowledgeFeedbackType, string> = {
[KnowledgeFeedbackType.Like]: "点赞",
[KnowledgeFeedbackType.Dislike]: "点踩",
[KnowledgeFeedbackType.NotHelpful]: "无帮助",
[KnowledgeFeedbackType.WrongCitation]: "引用错误",
[KnowledgeFeedbackType.Other]: "其他",
}
export enum KnowledgeRetrieveChannel {
IM = "im",
AgentAssist = "agent_assist",
API = "api",
Debug = "debug",
}
export const KnowledgeRetrieveChannelLabels: Record<KnowledgeRetrieveChannel, string> = {
[KnowledgeRetrieveChannel.IM]: "客服会话",
[KnowledgeRetrieveChannel.AgentAssist]: "客服助手",
[KnowledgeRetrieveChannel.API]: "API接口",
[KnowledgeRetrieveChannel.Debug]: "调试",
}
export enum KnowledgeRetrieveScene {
FirstResponse = "first_response",
Assist = "assist",
QA = "qa",
}
export const KnowledgeRetrieveSceneLabels: Record<KnowledgeRetrieveScene, string> = {
[KnowledgeRetrieveScene.FirstResponse]: "首次回复",
[KnowledgeRetrieveScene.Assist]: "辅助回复",
[KnowledgeRetrieveScene.QA]: "问答",
}
export enum ServiceStatus {
Idle = 0,
Busy = 1,
}
export const ServiceStatusLabels: Record<ServiceStatus, string> = {
[ServiceStatus.Idle]: "空闲",
[ServiceStatus.Busy]: "忙碌",
}
export enum Status {
Ok = 0,
Disabled = 1,
Deleted = 2,
}
export const StatusLabels: Record<Status, string> = {
[Status.Ok]: "启用",
[Status.Disabled]: "禁用",
[Status.Deleted]: "已删除",
}
export enum ThirdProvider {
OIDC = "oidc",
}
export const ThirdProviderLabels: Record<ThirdProvider, string> = {
[ThirdProvider.OIDC]: "OIDC",
}
export enum TicketStatus {
Pending = "pending",
InProgress = "in_progress",
Done = "done",
}
export const TicketStatusLabels: Record<TicketStatus, string> = {
[TicketStatus.Pending]: "待处理",
[TicketStatus.InProgress]: "处理中",
[TicketStatus.Done]: "已处理",
}
export enum UserType {
User = "user",
Employee = "employee",
}
export const UserTypeLabels: Record<UserType, string> = {
[UserType.User]: "访客",
[UserType.Employee]: "员工",
}
export enum VectorDBType {
Qdrant = "qdrant",
LanceDB = "lancedb",
}
export const VectorDBTypeLabels: Record<VectorDBType, string> = {
[VectorDBType.Qdrant]: "Qdrant",
[VectorDBType.LanceDB]: "LanceDB",
}

232
web/lib/im-editor-image.ts Normal file
View File

@@ -0,0 +1,232 @@
import type { Editor } from "@tiptap/react"
import Image from "@tiptap/extension-image"
export const MessageImageExtension = Image.extend({
addAttributes() {
return {
...this.parent?.(),
dataAssetId: {
default: null,
parseHTML: (element) => element.getAttribute("data-asset-id"),
renderHTML: (attributes) =>
attributes.dataAssetId ? { "data-asset-id": attributes.dataAssetId } : {},
},
dataProvider: {
default: null,
parseHTML: (element) => element.getAttribute("data-provider"),
renderHTML: (attributes) =>
attributes.dataProvider ? { "data-provider": attributes.dataProvider } : {},
},
dataStorageKey: {
default: null,
parseHTML: (element) => element.getAttribute("data-storage-key"),
renderHTML: (attributes) =>
attributes.dataStorageKey ? { "data-storage-key": attributes.dataStorageKey } : {},
},
}
},
addNodeView() {
return ({ node }) => {
const wrapper = document.createElement("span")
wrapper.className =
"agent-desk-editor-image-wrap relative inline-block max-w-full align-middle"
const image = document.createElement("img")
image.className = "agent-desk-editor-image"
image.draggable = true
const overlay = document.createElement("span")
overlay.className =
"agent-desk-editor-image-loading pointer-events-none absolute inset-0 hidden items-center justify-center rounded-lg bg-background/55 backdrop-blur-[1px]"
const spinner = document.createElement("span")
spinner.className =
"size-6 animate-spin rounded-full border-2 border-primary/25 border-t-primary shadow-sm"
overlay.appendChild(spinner)
wrapper.appendChild(image)
wrapper.appendChild(overlay)
const applyAttrs = (attrs: Record<string, unknown>) => {
setImageAttr(image, "src", attrs.src)
setImageAttr(image, "alt", attrs.alt)
setImageAttr(image, "title", attrs.title)
setImageAttr(image, "data-asset-id", attrs.dataAssetId)
setImageAttr(image, "data-provider", attrs.dataProvider)
setImageAttr(image, "data-storage-key", attrs.dataStorageKey)
setImageUploading(wrapper, Boolean(String(attrs.title ?? "").startsWith("uploading-")))
}
applyAttrs(node.attrs)
return {
dom: wrapper,
update: (updatedNode) => {
if (updatedNode.type.name !== node.type.name) {
return false
}
applyAttrs(updatedNode.attrs)
return true
},
ignoreMutation: () => true,
}
}
},
})
export type UploadedEditorImage = {
assetId: string
provider: string
storageKey: string
filename?: string
}
export type UploadedEditorImageMap = Map<string, UploadedEditorImage>
export function removeEditorImageByTitle(editor: Editor, title: string) {
const { state } = editor
let targetPos: number | null = null
state.doc.descendants((node, pos) => {
if (node.type.name === "image" && node.attrs.title === title) {
targetPos = pos
return false
}
return true
})
if (targetPos === null) {
return
}
editor.chain().focus().deleteRange({ from: targetPos, to: targetPos + 1 }).run()
}
export function markEditorImageUploadedByTitle(
editor: Editor,
title: string,
uploaded: UploadedEditorImage,
uploadedImages: UploadedEditorImageMap
) {
uploadedImages.set(title, uploaded)
const image = findEditorImageElementByTitle(editor, title)
if (!image) {
return
}
image.setAttribute("data-asset-id", uploaded.assetId)
image.setAttribute("data-provider", uploaded.provider)
image.setAttribute("data-storage-key", uploaded.storageKey)
image.setAttribute("alt", uploaded.filename || image.getAttribute("alt") || "image")
image.classList.remove("agent-desk-editor-image-uploading")
image.removeAttribute("data-uploading")
image.removeAttribute("title")
setImageUploading(image.closest(".agent-desk-editor-image-wrap"), false)
}
export function setEditorImageUploadingByTitle(editor: Editor, title: string) {
const image = findEditorImageElementByTitle(editor, title)
if (!image) {
return
}
image.classList.add("agent-desk-editor-image-uploading")
image.setAttribute("data-uploading", "true")
setImageUploading(image.closest(".agent-desk-editor-image-wrap"), true)
}
export function buildSendableEditorHTML(
html: string,
uploadedImages?: UploadedEditorImageMap
) {
if (typeof document === "undefined" || !html.includes("<img")) {
return html
}
const template = document.createElement("template")
template.innerHTML = html
for (const image of Array.from(template.content.querySelectorAll("img"))) {
const title = image.getAttribute("title") ?? ""
const uploaded = title ? uploadedImages?.get(title) : undefined
if (uploaded) {
image.setAttribute("data-asset-id", uploaded.assetId)
image.setAttribute("data-provider", uploaded.provider)
image.setAttribute("data-storage-key", uploaded.storageKey)
image.setAttribute("alt", uploaded.filename || image.getAttribute("alt") || "image")
image.removeAttribute("title")
}
if (
image.getAttribute("data-asset-id") &&
image.getAttribute("src")?.startsWith("blob:")
) {
image.removeAttribute("src")
}
}
return template.innerHTML
}
export function hasUploadingEditorImages(
html: string,
uploadedImages?: UploadedEditorImageMap
) {
if (typeof document === "undefined" || !html.includes("<img")) {
return /<img\b[^>]*\btitle=(["'])uploading-[^"']+\1/i.test(html)
}
const template = document.createElement("template")
template.innerHTML = html
return Array.from(template.content.querySelectorAll("img")).some((image) =>
isUnfinishedUploadingImage(image, uploadedImages)
)
}
export function revokeEditorObjectUrl(urls: Set<string>, objectUrl: string) {
if (!urls.delete(objectUrl)) {
return
}
URL.revokeObjectURL(objectUrl)
}
export function revokeEditorObjectUrls(urls: Set<string>) {
for (const objectUrl of urls) {
URL.revokeObjectURL(objectUrl)
}
urls.clear()
}
function findEditorImageElementByTitle(editor: Editor, title: string) {
const escapedTitle =
typeof CSS !== "undefined" && typeof CSS.escape === "function"
? CSS.escape(title)
: title.replace(/["\\]/g, "\\$&")
return editor.view.dom.querySelector<HTMLImageElement>(
`img[title="${escapedTitle}"]`
)
}
function isUnfinishedUploadingImage(
image: HTMLImageElement,
uploadedImages?: UploadedEditorImageMap
) {
const title = image.getAttribute("title") ?? ""
return title.startsWith("uploading-") && !uploadedImages?.has(title)
}
function setImageAttr(image: HTMLImageElement, name: string, value: unknown) {
const normalized = typeof value === "string" ? value : ""
if (!normalized) {
image.removeAttribute(name)
return
}
if (image.getAttribute(name) !== normalized) {
image.setAttribute(name, normalized)
}
}
function setImageUploading(wrapper: Element | null, uploading: boolean) {
if (!(wrapper instanceof HTMLElement)) {
return
}
wrapper.classList.toggle("agent-desk-editor-image-wrap-uploading", uploading)
const overlay = wrapper.querySelector<HTMLElement>(".agent-desk-editor-image-loading")
if (overlay) {
overlay.classList.toggle("hidden", !uploading)
overlay.classList.toggle("flex", uploading)
}
}

177
web/lib/im-message-merge.ts Normal file
View File

@@ -0,0 +1,177 @@
import { parseMessageAssetPayload } from "@/lib/im-message"
export type MergeableImMessage = {
id: number
conversationId: number
clientMsgId?: string
senderType: string
senderId: number
senderName?: string
senderAvatar?: string
messageType: string
content: string
payload?: string
sendStatus: number
sentAt?: string
deliveredAt?: string
readAt?: string
customerRead: boolean
customerReadAt?: string
agentRead: boolean
agentReadAt?: string
recalledAt?: string
quotedMessageId?: number
}
export function mergeImMessagesByIdAsc<T extends MergeableImMessage>(
a: T[],
b: T[]
): T[] {
const byId = new Map<number, T>()
for (const message of a) {
byId.set(message.id, message)
}
for (const message of b) {
const existing = byId.get(message.id)
byId.set(message.id, existing ? mergeImMessage(existing, message) : message)
}
return Array.from(byId.values()).sort((x, y) => x.id - y.id)
}
export function parseImMessageCursorId(cursor: string): number {
const value = Number.parseInt(cursor, 10)
return Number.isFinite(value) && value > 0 ? value : 0
}
export function cursorFromLoadedImMessages<T extends Pick<MergeableImMessage, "id">>(
messages: T[]
): string {
if (messages.length === 0) {
return ""
}
return String(Math.min(...messages.map((message) => message.id)))
}
export function hasMoreAfterLatestImMessageMerge<
T extends Pick<MergeableImMessage, "id">,
>(args: {
previousMessages: T[]
previousHasMore: boolean
merged: T[]
apiHasMore: boolean
}): boolean {
const prevMin = minImMessageId(args.previousMessages)
const mergedMin = minImMessageId(args.merged)
if (mergedMin === null) {
return Boolean(args.apiHasMore)
}
if (!args.previousHasMore && prevMin !== null && mergedMin >= prevMin) {
return false
}
return args.previousHasMore || Boolean(args.apiHasMore)
}
function minImMessageId<T extends Pick<MergeableImMessage, "id">>(
messages: T[]
): number | null {
if (messages.length === 0) {
return null
}
return Math.min(...messages.map((message) => message.id))
}
export function mergeImMessage<T extends MergeableImMessage>(
existing: T,
incoming: T
): T {
const normalizedIncoming = normalizeDynamicImageContent(
existing,
normalizeDynamicImagePayload(existing, incoming)
)
return isSameImMessage(existing, normalizedIncoming) ? existing : normalizedIncoming
}
function normalizeDynamicImagePayload<T extends MergeableImMessage>(
existing: T,
incoming: T
): T {
if (
existing.messageType !== "image" ||
incoming.messageType !== "image" ||
!existing.payload ||
!incoming.payload
) {
return incoming
}
const existingAsset = parseMessageAssetPayload(existing.payload)
const incomingAsset = parseMessageAssetPayload(incoming.payload)
if (
!existingAsset?.assetId ||
existingAsset.assetId !== incomingAsset?.assetId ||
existing.payload === incoming.payload
) {
return incoming
}
return {
...incoming,
payload: existing.payload,
}
}
function normalizeDynamicImageContent<T extends MergeableImMessage>(
existing: T,
incoming: T
): T {
if (
existing.content === incoming.content ||
!existing.content.includes("data-asset-id") ||
!incoming.content.includes("data-asset-id")
) {
return incoming
}
if (getStableHTMLContentKey(existing.content) !== getStableHTMLContentKey(incoming.content)) {
return incoming
}
return {
...incoming,
content: existing.content,
}
}
function getStableHTMLContentKey(html: string): string {
if (typeof document === "undefined") {
return html.replace(
/(<img\b[^>]*\bdata-asset-id=(["'])[^"']+\2[^>]*?)\s+(?:src|srcset)=(["'])[^"']*\3/gi,
"$1"
)
}
const template = document.createElement("template")
template.innerHTML = html
for (const image of Array.from(template.content.querySelectorAll("img"))) {
if (image.getAttribute("data-asset-id")) {
image.removeAttribute("src")
image.removeAttribute("srcset")
}
}
return template.innerHTML
}
function isSameImMessage(a: MergeableImMessage, b: MergeableImMessage): boolean {
const aKeys = Object.keys(a)
const bKeys = Object.keys(b)
if (aKeys.length !== bKeys.length) {
return false
}
return aKeys.every((key) => {
const field = key as keyof MergeableImMessage
return Object.prototype.hasOwnProperty.call(b, key) && a[field] === b[field]
})
}

153
web/lib/im-message.ts Normal file
View File

@@ -0,0 +1,153 @@
import MarkdownIt from "markdown-it"
import { translateCurrentMessage } from "@/i18n/messages"
export type MessageAssetPayload = {
assetId: string
filename?: string
fileSize?: number
mimeType?: string
url?: string
}
const messageMarkdown = new MarkdownIt({
html: false,
linkify: true,
breaks: true,
})
function t(key: string) {
return translateCurrentMessage(key)
}
export function parseMessageAssetPayload(payload?: string): MessageAssetPayload | null {
if (!payload?.trim()) {
return null
}
try {
const parsed = JSON.parse(payload) as MessageAssetPayload
if (!parsed?.assetId?.trim()) {
return null
}
return parsed
} catch {
return null
}
}
export function renderIMMessageHTML(message: {
messageType: string
content: string
payload?: string
}) {
if (message.messageType === "html") {
return message.content
}
const asset = parseMessageAssetPayload(message.payload)
if (message.messageType === "image") {
if (asset?.url) {
return `<p><img src="${escapeHTMLAttr(asset.url)}" alt="${escapeHTMLAttr(
asset.filename || "image"
)}"></p>`
}
return `<p>${escapeHTML(t("supportChat.imageSummary"))}</p>`
}
if (message.messageType === "attachment") {
if (asset?.url) {
const title = escapeHTML(asset.filename || message.content || t("supportChat.attachmentSummary"))
const meta = formatFileSize(asset.fileSize ?? 0)
const metaHTML = meta ? `<div class="im-attachment-meta">${escapeHTML(meta)}</div>` : ""
return `<div class="im-attachment"><a href="${escapeHTMLAttr(
asset.url
)}" target="_blank" rel="noreferrer" download="${escapeHTMLAttr(
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>`
}
return `<p>${escapeHTML(message.content || t("supportChat.attachmentSummary"))}</p>`
}
return renderTextMessageHTML(message.content || "")
}
export function summarizeIMMessage(message: {
messageType: string
content: string
payload?: string
}) {
if (message.messageType === "image") {
return t("supportChat.imageSummary")
}
if (message.messageType === "attachment") {
const asset = parseMessageAssetPayload(message.payload)
return asset?.filename?.trim()
? `${t("supportChat.attachmentSummary")} ${asset.filename.trim()}`
: t("supportChat.attachmentSummary")
}
if (message.messageType === "html") {
const text = extractTextFromHTML(message.content)
if (text.trim()) {
return text.substring(0, 100)
}
if (message.content.includes("<img")) {
return t("supportChat.imageSummary")
}
return t("supportChat.messageSummary")
}
return message.content?.substring(0, 100) || t("supportChat.messageSummary")
}
export function formatFileSize(size: number) {
if (!Number.isFinite(size) || size <= 0) {
return ""
}
const units = ["B", "KB", "MB", "GB"]
let value = size
let index = 0
while (value >= 1024 && index < units.length - 1) {
value /= 1024
index += 1
}
const digits = value >= 10 || index === 0 ? 0 : 1
return `${value.toFixed(digits)} ${units[index]}`
}
function extractTextFromHTML(html: string): string {
if (typeof document === "undefined") {
return ""
}
const div = document.createElement("div")
div.innerHTML = html
return div.textContent || div.innerText || ""
}
function renderTextMessageHTML(content: string) {
const value = content.trim()
if (!value) {
return "<p></p>"
}
return messageMarkdown.render(value)
}
function escapeHTML(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
.replaceAll("\n", "<br>")
}
function escapeHTMLAttr(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll('"', "&quot;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
}
function getAttachmentIconSVG() {
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><path d="M14 2v6h6"></path><path d="M9 15h6"></path><path d="M9 11h2"></path></svg>`
}

View File

@@ -0,0 +1,172 @@
import type { AgentConversation, AgentMessage } from "@/lib/api/agent"
import type { ImConversation, ImMessage } from "@/lib/api/im"
import { mergeImMessagesByIdAsc } from "@/lib/im-message-merge"
import { summarizeIMMessage } from "@/lib/im-message"
export type RealtimeMessage = AgentMessage | ImMessage
export type RealtimeConversation = AgentConversation | ImConversation
export type RealtimeMessageCreatedPayload<TMessage extends RealtimeMessage> = {
conversationId?: number
messageId?: number
message?: TMessage
senderType?: string
senderId?: number
senderName?: string
senderAvatar?: string
messageType?: string
content?: string
payload?: string
sendStatus?: number
sentAt?: string
}
export type RealtimeConversationPatch = Partial<RealtimeConversation> & {
conversationId?: number
}
export function normalizeRealtimeMessage<TMessage extends RealtimeMessage>(
payload: RealtimeMessageCreatedPayload<TMessage> | null | undefined
): TMessage | null {
if (!payload) {
return null
}
if (payload.message?.id) {
return payload.message
}
const id = payload.messageId ?? 0
const conversationId = payload.conversationId ?? 0
if (id <= 0 || conversationId <= 0) {
return null
}
return {
id,
conversationId,
senderType: payload.senderType ?? "",
senderId: payload.senderId ?? 0,
senderName: payload.senderName,
senderAvatar: payload.senderAvatar,
messageType: payload.messageType ?? "",
content: payload.content ?? "",
payload: payload.payload,
sendStatus: payload.sendStatus ?? 0,
sentAt: payload.sentAt,
customerRead: false,
agentRead: false,
} as TMessage
}
export function mergeRealtimeMessage<TMessage extends RealtimeMessage>(
messages: TMessage[],
message: TMessage | null | undefined
): TMessage[] {
if (!message) {
return messages
}
return mergeImMessagesByIdAsc(messages, [message])
}
export function patchConversation<TConversation extends RealtimeConversation>(
conversation: TConversation | null,
patch: RealtimeConversationPatch | null | undefined
): TConversation | null {
if (!conversation || !patch) {
return conversation
}
const id = patch.id ?? patch.conversationId
if (!id || conversation.id !== id) {
return conversation
}
const fields = { ...patch }
delete fields.conversationId
return {
...conversation,
...fields,
} as TConversation
}
export function patchConversationList<TConversation extends RealtimeConversation>(
conversations: TConversation[],
patch: RealtimeConversationPatch | null | undefined
): TConversation[] {
if (!patch) {
return conversations
}
const id = patch.id ?? patch.conversationId
if (!id) {
return conversations
}
let changed = false
const next = conversations.map((item) => {
const patched = patchConversation(item, patch)
if (patched !== item) {
changed = true
}
return patched ?? item
})
return changed ? next : conversations
}
export function patchConversationWithMessage<
TConversation extends RealtimeConversation,
TMessage extends RealtimeMessage,
>(conversation: TConversation | null, message: TMessage | null | undefined) {
if (!conversation || !message || conversation.id !== message.conversationId) {
return conversation
}
return {
...conversation,
lastMessageId: message.id,
lastMessageAt: message.sentAt ?? conversation.lastMessageAt,
lastActiveAt: message.sentAt ?? conversation.lastActiveAt,
lastMessageSummary: summarizeIMMessage(message),
} as TConversation
}
export function patchConversationListWithMessage<
TConversation extends RealtimeConversation,
TMessage extends RealtimeMessage,
>(conversations: TConversation[], message: TMessage | null | undefined) {
if (!message) {
return conversations
}
let changed = false
const next = conversations.map((item) => {
const patched = patchConversationWithMessage(item, message)
if (patched !== item) {
changed = true
}
return patched ?? item
})
return changed ? next : conversations
}
export function markMessagesReadToMessageId<TMessage extends RealtimeMessage>(
messages: TMessage[],
messageId: number,
reader: "agent" | "customer",
readAt?: string
): TMessage[] {
if (messageId <= 0) {
return messages
}
let changed = false
const next = messages.map((message) => {
if (message.id > messageId) {
return message
}
if (reader === "agent") {
if (message.agentRead && message.agentReadAt === readAt) {
return message
}
changed = true
return { ...message, agentRead: true, agentReadAt: readAt } as TMessage
}
if (message.customerRead && message.customerReadAt === readAt) {
return message
}
changed = true
return { ...message, customerRead: true, customerReadAt: readAt } as TMessage
})
return changed ? next : messages
}

40
web/lib/im-realtime.ts Normal file
View File

@@ -0,0 +1,40 @@
import { createWebSocketBaseUrl } from "@/lib/api/websocket"
import { getCustomerSessionToken, type ImMessage } from "@/lib/api/im"
import { readSupportChatRuntimeConfig } from "@/lib/sdk/runtime-config"
import type {
RealtimeConversationPatch,
RealtimeMessageCreatedPayload,
} from "@/lib/im-realtime-state"
import type { AgentActivityEventPayload } from "@/lib/agent-activity-state"
export type RealtimeAgentActivityPayload = AgentActivityEventPayload
export type ImRealtimeEnvelope = {
type: string
topic?: string
data?: RealtimeMessageCreatedPayload<ImMessage> &
RealtimeConversationPatch &
RealtimeAgentActivityPayload & {
customerSessionToken?: string
expiresAt?: string
}
payload?: RealtimeMessageCreatedPayload<ImMessage> &
RealtimeConversationPatch &
RealtimeAgentActivityPayload & {
customerSessionToken?: string
expiresAt?: string
}
}
export function createImRealtimeConnection() {
const config = readSupportChatRuntimeConfig()
const apiBaseUrl = (config.apiBaseUrl || config.baseUrl || "").trim()
const baseUrl = apiBaseUrl
? apiBaseUrl.replace(/^http/, "ws").replace(/\/$/, "")
: createWebSocketBaseUrl()
const channelId = encodeURIComponent(config.channelId || "")
const customerSessionToken = getCustomerSessionToken()
return new WebSocket(
`${baseUrl}/api/ws/open?channelId=${channelId}&customerSessionToken=${encodeURIComponent(customerSessionToken)}`
)
}

View File

@@ -0,0 +1,56 @@
import assert from "node:assert/strict"
import test from "node:test"
import ts from "typescript"
import { readFile } from "node:fs/promises"
import vm from "node:vm"
async function loadModule() {
const source = await readFile(new URL("./knowledge-i18n.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.CommonJS,
},
fileName: "knowledge-i18n.ts",
})
const sandbox = {
exports: {},
module: { exports: {} },
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return sandbox.module.exports
}
const t = (key) =>
({
"knowledge.channelIM": "Conversations",
"knowledge.channelAgentAssist": "Agent Assist",
"knowledge.channelAPI": "API",
"knowledge.channelDebug": "Debug",
"knowledge.sceneFirstResponse": "First response",
"knowledge.sceneAssist": "Assisted reply",
"knowledge.sceneQA": "Q&A",
"knowledge.answerNormal": "Answered",
"knowledge.answerNoAnswer": "No answer",
"knowledge.answerFallback": "Fallback",
"knowledge.answerBlocked": "Blocked",
"knowledge.chunkFixed": "Fixed length",
"knowledge.chunkStructured": "Structured chunks",
"knowledge.chunkFAQ": "Q&A chunks",
"knowledge.chunkSemantic": "Semantic chunks",
})[key] ?? key
test("localizes knowledge retrieve enum labels from stable values", async () => {
const {
getKnowledgeAnswerStatusLabel,
getKnowledgeChunkProviderLabel,
getKnowledgeRetrieveChannelLabel,
getKnowledgeRetrieveSceneLabel,
} = await loadModule()
assert.equal(getKnowledgeRetrieveChannelLabel("im", "\u5ba2\u670d\u4f1a\u8bdd", t), "Conversations")
assert.equal(getKnowledgeRetrieveSceneLabel("first_response", "\u9996\u6b21\u56de\u590d", t), "First response")
assert.equal(getKnowledgeAnswerStatusLabel(2, "\u65e0\u7b54\u6848", t), "No answer")
assert.equal(getKnowledgeChunkProviderLabel("semantic", t), "Semantic chunks")
})

48
web/lib/knowledge-i18n.ts Normal file
View File

@@ -0,0 +1,48 @@
type TFunction = (key: string, values?: Record<string, string | number>) => string
const CHANNEL_LABEL_KEYS: Record<string, string> = {
im: "knowledge.channelIM",
agent_assist: "knowledge.channelAgentAssist",
api: "knowledge.channelAPI",
debug: "knowledge.channelDebug",
}
const SCENE_LABEL_KEYS: Record<string, string> = {
first_response: "knowledge.sceneFirstResponse",
assist: "knowledge.sceneAssist",
qa: "knowledge.sceneQA",
}
const ANSWER_STATUS_LABEL_KEYS: Record<number, string> = {
1: "knowledge.answerNormal",
2: "knowledge.answerNoAnswer",
3: "knowledge.answerFallback",
4: "knowledge.answerBlocked",
}
const PROVIDER_LABEL_KEYS: Record<string, string> = {
fixed: "knowledge.chunkFixed",
structured: "knowledge.chunkStructured",
faq: "knowledge.chunkFAQ",
semantic: "knowledge.chunkSemantic",
}
export function getKnowledgeRetrieveChannelLabel(value: string, fallback: string, t: TFunction) {
return translateByKey(CHANNEL_LABEL_KEYS[value], fallback, t)
}
export function getKnowledgeRetrieveSceneLabel(value: string, fallback: string, t: TFunction) {
return translateByKey(SCENE_LABEL_KEYS[value], fallback, t)
}
export function getKnowledgeAnswerStatusLabel(value: number, fallback: string, t: TFunction) {
return translateByKey(ANSWER_STATUS_LABEL_KEYS[value], fallback, t)
}
export function getKnowledgeChunkProviderLabel(value: string, t: TFunction) {
return translateByKey(PROVIDER_LABEL_KEYS[value], value, t)
}
function translateByKey(key: string | undefined, fallback: string, t: TFunction) {
return key ? t(key) : fallback
}

View File

@@ -0,0 +1,92 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import ts from "typescript"
import { readFile } from "node:fs/promises"
import vm from "node:vm"
async function loadModule() {
const source = await readFile(
new URL("./navigation-active.ts", import.meta.url),
"utf8"
)
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.CommonJS,
},
fileName: "navigation-active.ts",
})
const sandbox = {
exports: {},
module: { exports: {} },
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return sandbox.module.exports
}
describe("isDashboardNavItemActive", () => {
it("matches exact dashboard paths and nested child routes", async () => {
const { isDashboardNavItemActive } = await loadModule()
assert.equal(isDashboardNavItemActive("/dashboard/tickets", "/dashboard/tickets"), true)
assert.equal(
isDashboardNavItemActive("/dashboard/tickets/123", "/dashboard/tickets"),
true
)
assert.equal(
isDashboardNavItemActive("/dashboard/tickets-extra", "/dashboard/tickets"),
false
)
assert.equal(
isDashboardNavItemActive(
"/dashboard/channels-archive",
"/dashboard/channels"
),
false
)
})
it("only marks the dashboard home item active on the exact dashboard path", async () => {
const { isDashboardNavItemActive } = await loadModule()
assert.equal(isDashboardNavItemActive("/dashboard", "/dashboard"), true)
assert.equal(isDashboardNavItemActive("/dashboard/tickets", "/dashboard"), false)
})
})
describe("dashboardNavSectionHasActiveItem", () => {
it("detects whether a sidebar group contains the current route", async () => {
const { dashboardNavSectionHasActiveItem } = await loadModule()
const items = [
{ url: "/dashboard/tags" },
{ url: "/dashboard/quick-replies" },
]
assert.equal(
dashboardNavSectionHasActiveItem(items, "/dashboard/quick-replies/create"),
true
)
assert.equal(dashboardNavSectionHasActiveItem(items, "/dashboard/users"), false)
})
})
describe("dashboard nav section storage helpers", () => {
it("builds stable localStorage keys from section identifiers", async () => {
const { getDashboardNavSectionStorageKey } = await loadModule()
assert.equal(
getDashboardNavSectionStorageKey("nav.receptionCenter"),
"dashboard.sidebar.navSection.nav.receptionCenter.open"
)
})
it("parses stored open states and ignores unknown values", async () => {
const { parseDashboardNavSectionOpenState } = await loadModule()
assert.equal(parseDashboardNavSectionOpenState("true"), true)
assert.equal(parseDashboardNavSectionOpenState("false"), false)
assert.equal(parseDashboardNavSectionOpenState(null), undefined)
assert.equal(parseDashboardNavSectionOpenState("bad"), undefined)
})
})

View File

@@ -0,0 +1,40 @@
export type DashboardNavActiveItem = {
url: string
}
const DASHBOARD_NAV_SECTION_STORAGE_PREFIX = "dashboard.sidebar.navSection"
function normalizePath(path: string) {
return path !== "/" ? path.replace(/\/+$/, "") : path
}
export function isDashboardNavItemActive(pathname: string, itemUrl: string) {
const currentPath = normalizePath(pathname)
const targetPath = normalizePath(itemUrl)
if (targetPath === "/" || targetPath === "/dashboard") {
return currentPath === targetPath
}
return currentPath === targetPath || currentPath.startsWith(targetPath + "/")
}
export function dashboardNavSectionHasActiveItem(
items: ReadonlyArray<DashboardNavActiveItem>,
pathname: string
) {
return items.some((item) => isDashboardNavItemActive(pathname, item.url))
}
export function getDashboardNavSectionStorageKey(sectionKey: string) {
return `${DASHBOARD_NAV_SECTION_STORAGE_PREFIX}.${sectionKey}.open`
}
export function parseDashboardNavSectionOpenState(value: string | null) {
if (value === "true") {
return true
}
if (value === "false") {
return false
}
return undefined
}

352
web/lib/navigation.tsx Normal file
View File

@@ -0,0 +1,352 @@
import {
BotMessageSquareIcon,
BookOpenIcon,
BrainCircuitIcon,
Building2Icon,
CalendarClockIcon,
FileTextIcon,
CircleHelpIcon,
GlobeIcon,
KeyRoundIcon,
LayoutDashboardIcon,
ListChecksIcon,
MessageSquareCodeIcon,
MessageSquareMoreIcon,
SettingsIcon,
ShieldCheckIcon,
TagsIcon,
UserCogIcon,
UsersIcon,
WorkflowIcon,
} from "lucide-react";
import type { ReactNode } from "react";
/** Keep in sync with backend internal/pkg/constants/auth.go RoleCodeSuperAdmin. */
export const DASHBOARD_ROLE_SUPER_ADMIN = "super_admin";
export type DashboardNavMenuItem = {
title: string;
titleKey: string;
url: string;
icon: ReactNode;
};
export type DashboardNavItemConfig = Omit<DashboardNavMenuItem, "title"> & {
/**
* Keep in sync with backend Permission.Code. Missing value means any signed-in
* admin can see the module.
*/
requiredPermission?: string;
};
export type DashboardNavSectionConfig = {
titleKey: string;
icon: ReactNode;
items: DashboardNavItemConfig[];
};
function navItemVisible(
item: DashboardNavItemConfig,
superAdmin: boolean,
permissionSet: Set<string>,
): boolean {
if (superAdmin) {
return true;
}
if (!item.requiredPermission) {
return true;
}
return permissionSet.has(item.requiredPermission);
}
export function filterDashboardNavForSession(
permissions: readonly string[] | undefined,
roles: readonly string[] | undefined,
): { titleKey: string; icon: ReactNode; items: DashboardNavMenuItem[] }[] {
const superAdmin = roles?.includes(DASHBOARD_ROLE_SUPER_ADMIN) ?? false;
const permissionSet = new Set(permissions ?? []);
return dashboardNavSections
.map((section) => ({
titleKey: section.titleKey,
icon: section.icon,
items: section.items
.filter((item) => navItemVisible(item, superAdmin, permissionSet))
.map(({ titleKey, url, icon }) => ({ title: titleKey, titleKey, url, icon })),
}))
.filter((section) => section.items.length > 0);
}
export function filterDashboardSecondaryNavForSession(
permissions: readonly string[] | undefined,
roles: readonly string[] | undefined,
): DashboardNavMenuItem[] {
const superAdmin = roles?.includes(DASHBOARD_ROLE_SUPER_ADMIN) ?? false;
const permissionSet = new Set(permissions ?? []);
return dashboardSecondaryNav
.filter((item) => navItemVisible(item, superAdmin, permissionSet))
.map(({ titleKey, url, icon }) => ({ title: titleKey, titleKey, url, icon }));
}
export const dashboardNavSections: DashboardNavSectionConfig[] = [
// {
// title: "Overview",
// items: [
// {
// title: "Overview",
// url: "/",
// icon: <LayoutDashboardIcon />,
// },
// ],
// },
{
titleKey: "nav.receptionCenter",
icon: <BotMessageSquareIcon />,
items: [
{
titleKey: "nav.overview",
url: "/dashboard",
icon: <LayoutDashboardIcon />,
},
{
titleKey: "nav.setupWizard",
url: "/dashboard/setup",
icon: <ListChecksIcon />,
},
{
titleKey: "nav.conversations",
url: "/dashboard/conversations",
icon: <BotMessageSquareIcon />,
requiredPermission: "conversation.view",
},
{
titleKey: "nav.tickets",
url: "/dashboard/tickets",
icon: <FileTextIcon />,
requiredPermission: "ticket.view",
},
{
titleKey: "nav.conversationMonitor",
url: "/dashboard/conversation-monitor",
icon: <BotMessageSquareIcon />,
requiredPermission: "conversation.view",
},
{
titleKey: "nav.customers",
url: "/dashboard/customers",
icon: <UsersIcon />,
requiredPermission: "customer.view",
},
{
titleKey: "nav.companies",
url: "/dashboard/companies",
icon: <Building2Icon />,
requiredPermission: "company.view",
},
],
},
{
titleKey: "nav.supportCenter",
icon: <CircleHelpIcon />,
items: [
{
titleKey: "nav.supportDocs",
url: "/dashboard/support/docs",
icon: <BookOpenIcon />,
requiredPermission: "docPage.view",
},
{
titleKey: "nav.supportCommunity",
url: "/dashboard/support/community",
icon: <CircleHelpIcon />,
requiredPermission: "supportCommunity.view",
},
{
titleKey: "nav.supportCommunityCategories",
url: "/dashboard/support/community-categories",
icon: <TagsIcon />,
requiredPermission: "supportCommunity.view",
},
{
titleKey: "nav.supportConfig",
url: "/dashboard/support/config",
icon: <SettingsIcon />,
requiredPermission: "supportConfig.view",
},
],
},
{
titleKey: "nav.agentConfig",
icon: <UserCogIcon />,
items: [
{
titleKey: "nav.tags",
url: "/dashboard/tags",
icon: <TagsIcon />,
requiredPermission: "tag.view",
},
{
titleKey: "nav.quickReplies",
url: "/dashboard/quick-replies",
icon: <MessageSquareMoreIcon />,
requiredPermission: "quickReply.view",
},
{
titleKey: "nav.agents",
url: "/dashboard/agents",
icon: <UserCogIcon />,
requiredPermission: "agent.view",
},
{
titleKey: "nav.agentTeamSchedules",
url: "/dashboard/agent-team-schedules",
icon: <CalendarClockIcon />,
requiredPermission: "agentTeamSchedule.view",
},
{
titleKey: "nav.channels",
url: "/dashboard/channels",
icon: <GlobeIcon />,
requiredPermission: "channel.view",
},
],
},
{
titleKey: "nav.aiCapabilities",
icon: <BrainCircuitIcon />,
items: [
{
titleKey: "nav.knowledge",
url: "/dashboard/knowledge",
icon: <FileTextIcon />,
requiredPermission: "knowledgeBase.view",
},
{
titleKey: "nav.aiConfigs",
url: "/dashboard/ai-configs",
icon: <BrainCircuitIcon />,
requiredPermission: "aiConfig.view",
},
{
titleKey: "nav.aiAgents",
url: "/dashboard/ai-agents",
icon: <MessageSquareMoreIcon />,
requiredPermission: "aiAgent.view",
},
{
titleKey: "nav.workflows",
url: "/dashboard/ai-workflows",
icon: <WorkflowIcon />,
requiredPermission: "aiAgent.view",
},
{
titleKey: "nav.skillDefinition",
url: "/dashboard/skill-definition",
icon: <MessageSquareCodeIcon />,
requiredPermission: "skillDefinition.view",
},
{
titleKey: "nav.mcp",
url: "/dashboard/mcp",
icon: <MessageSquareCodeIcon />,
requiredPermission: "mcp.view",
},
{
titleKey: "nav.workflowRuns",
url: "/dashboard/ai-workflow-runs",
icon: <WorkflowIcon />,
requiredPermission: "aiAgent.view",
},
{
titleKey: "nav.agentRuns",
url: "/dashboard/agent-runs",
icon: <BotMessageSquareIcon />,
requiredPermission: "aiAgent.view",
},
],
},
{
titleKey: "nav.system",
icon: <ShieldCheckIcon />,
items: [
{
titleKey: "nav.users",
url: "/dashboard/users",
icon: <UsersIcon />,
requiredPermission: "user.view",
},
{
titleKey: "nav.roles",
url: "/dashboard/roles",
icon: <ShieldCheckIcon />,
requiredPermission: "role.view",
},
{
titleKey: "nav.permissions",
url: "/dashboard/permissions",
icon: <KeyRoundIcon />,
requiredPermission: "permission.view",
},
],
},
];
export const dashboardSecondaryNav: DashboardNavItemConfig[] = [
// {
// title: "System Settings",
// url: "/settings",
// icon: <Settings2Icon />,
// },
// {
// title: "Documentation Center",
// url: "/help",
// icon: <LifeBuoyIcon />,
// },
];
export const dashboardQuickActions = [
{
title: "View Conversations",
icon: <BotMessageSquareIcon />,
},
{
title: "Invite Members",
icon: <UserCogIcon />,
},
{
title: "Connect Bot",
icon: <MessageSquareCodeIcon />,
},
] as const;
export function getPageTitle(pathname: string): string {
return getPageTitleKey(pathname);
}
export function getPageTitleKey(pathname: string): string {
let matchedTitle = "nav.dashboardHome";
let longestMatch = 0;
for (const section of dashboardNavSections) {
for (const item of section.items) {
if (pathname === item.url || pathname.startsWith(item.url + "/")) {
const matchLength = item.url.length;
if (matchLength > longestMatch) {
longestMatch = matchLength;
matchedTitle = item.titleKey;
}
}
}
}
for (const item of dashboardSecondaryNav) {
if (pathname === item.url || pathname.startsWith(item.url + "/")) {
const matchLength = item.url.length;
if (matchLength > longestMatch) {
longestMatch = matchLength;
matchedTitle = item.titleKey;
}
}
}
return matchedTitle;
}

View File

@@ -0,0 +1,118 @@
import assert from "node:assert/strict"
import test from "node:test"
import ts from "typescript"
import { readFile } from "node:fs/promises"
import vm from "node:vm"
const messageBundles = {}
async function loadBundle(locale) {
if (!messageBundles[locale]) {
const source = await readFile(new URL(`../messages/${locale}.json`, import.meta.url), "utf8")
messageBundles[locale] = JSON.parse(source)
}
return messageBundles[locale]
}
function lookupMessage(bundle, key) {
return key.split(".").reduce((value, segment) => (value == null ? undefined : value[segment]), bundle)
}
function translateMessage(locale, key, values) {
const message = lookupMessage(messageBundles[locale], key)
if (typeof message !== "string") {
return key
}
if (!values) {
return message
}
return message.replace(/\{(\w+)\}/g, (match, name) => String(values[name] ?? match))
}
async function loadModule() {
await loadBundle("en-US")
await loadBundle("ru-RU")
const source = await readFile(new URL("./notification-i18n.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.CommonJS,
},
fileName: "notification-i18n.ts",
})
const sandbox = {
exports: {},
module: { exports: {} },
require: (id) => {
if (id === "@/i18n/config") {
return { DEFAULT_LOCALE: "zh-CN", normalizeLocale: normalizeLocaleStub }
}
if (id === "@/i18n/messages") {
return { translateMessage }
}
throw new Error(`unexpected import ${id}`)
},
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return sandbox.module.exports
}
function normalizeLocaleStub(value) {
const key = String(value ?? "").trim().toLowerCase()
if (key === "zh-cn" || key === "zh_cn" || key === "zh") {
return "zh-CN"
}
if (key === "en-us" || key === "en_us" || key === "en") {
return "en-US"
}
if (key === "ru-ru" || key === "ru_ru" || key === "ru") {
return "ru-RU"
}
return "zh-CN"
}
const ticketAssignedNotification = {
id: 1,
recipientUserId: 2,
title: "工单指派提醒",
content: "工单 TK-100 已指派给你\nCannot sign in\n指派原因: urgent",
notificationType: "ticket_assigned",
bizType: "ticket",
bizId: 100,
actionUrl: "/dashboard/tickets?ticketId=100",
}
test("localizes realtime ticket assignment notification to English", async () => {
const { localizeNotificationItem } = await loadModule()
const result = localizeNotificationItem(ticketAssignedNotification, "en-US")
assert.equal(result.title, "Ticket assigned")
assert.equal(
result.content,
"Ticket TK-100 has been assigned to you.\nCannot sign in\nAssignment reason: urgent"
)
})
test("localizes realtime ticket assignment notification to Russian", async () => {
await loadBundle("ru-RU")
const { localizeNotificationItem } = await loadModule()
const result = localizeNotificationItem(ticketAssignedNotification, "ru-RU")
assert.equal(result.title, "Обращение назначено")
assert.equal(
result.content,
"Обращение TK-100 назначено на вас.\nCannot sign in\nПричина назначения: urgent"
)
})
test("leaves Chinese notifications untouched for the Chinese locale", async () => {
const { localizeNotificationItem } = await loadModule()
const result = localizeNotificationItem(ticketAssignedNotification, "zh-CN")
assert.equal(result.title, ticketAssignedNotification.title)
assert.equal(result.content, ticketAssignedNotification.content)
})

View File

@@ -0,0 +1,117 @@
import { normalizeLocale } from "@/i18n/config"
import { translateMessage } from "@/i18n/messages"
type LocalizableNotification = {
title: string
content: string
notificationType: string
}
const TICKET_ASSIGNED_TITLE = "\u5de5\u5355\u6307\u6d3e\u63d0\u9192"
const CONVERSATION_TRANSFERRED_TITLE = "\u4f1a\u8bdd\u8f6c\u63a5\u63d0\u9192"
const CONVERSATION_AUTO_ASSIGNED_TITLE = "\u4f1a\u8bdd\u81ea\u52a8\u5206\u914d\u63d0\u9192"
const CONVERSATION_ASSIGNED_TITLE = "\u4f1a\u8bdd\u5206\u914d\u63d0\u9192"
const TICKET_ASSIGNED_PATTERN = /^\u5de5\u5355 (.+) \u5df2\u6307\u6d3e\u7ed9\u4f60$/
const CONVERSATION_ASSIGNED_PATTERN = /^\u4f1a\u8bdd #([0-9]+) \u5df2\u5206\u914d\u7ed9\u4f60$/
const ASSIGNMENT_REASON_PREFIX = "\u6307\u6d3e\u539f\u56e0: "
const CONVERSATION_ASSIGNMENT_REASON_PREFIX = "\u5206\u914d\u539f\u56e0: "
const TRANSFER_REASON_PREFIX = "\u8f6c\u63a5\u539f\u56e0: "
export function localizeNotificationItem<T extends LocalizableNotification>(
notification: T,
locale: string
): T {
const normalizedLocale = normalizeLocale(locale)
if (normalizedLocale === "zh-CN") {
return notification
}
if (notification.notificationType === "ticket_assigned") {
return {
...notification,
title: localizeNotificationTitle(notification.title, normalizedLocale),
content: localizeTicketAssignedContent(notification.content, normalizedLocale),
}
}
if (notification.notificationType === "conversation_assigned") {
return {
...notification,
title: localizeNotificationTitle(notification.title, normalizedLocale),
content: localizeConversationAssignedContent(notification.content, normalizedLocale),
}
}
return notification
}
function localizeTicketAssignedContent(content: string, locale: ReturnType<typeof normalizeLocale>) {
const lines = splitNotificationLines(content)
if (lines.length === 0) {
return content
}
const match = lines[0].match(TICKET_ASSIGNED_PATTERN)
if (match?.[1]) {
lines[0] = translateMessage(locale, "notification.ticketAssignedLine", {
ticketNo: match[1],
})
}
return lines
.map((line) =>
line.startsWith(ASSIGNMENT_REASON_PREFIX)
? translateMessage(locale, "notification.assignmentReason", {
reason: line.slice(ASSIGNMENT_REASON_PREFIX.length),
})
: line
)
.join("\n")
}
function localizeConversationAssignedContent(content: string, locale: ReturnType<typeof normalizeLocale>) {
const lines = splitNotificationLines(content)
if (lines.length === 0) {
return content
}
const match = lines[0].match(CONVERSATION_ASSIGNED_PATTERN)
if (match?.[1]) {
lines[0] = translateMessage(locale, "notification.conversationAssignedLine", {
conversationId: match[1],
})
}
return lines
.map((line) => {
if (line.startsWith(CONVERSATION_ASSIGNMENT_REASON_PREFIX)) {
return translateMessage(locale, "notification.assignmentReason", {
reason: line.slice(CONVERSATION_ASSIGNMENT_REASON_PREFIX.length),
})
}
if (line.startsWith(TRANSFER_REASON_PREFIX)) {
return translateMessage(locale, "notification.transferReason", {
reason: line.slice(TRANSFER_REASON_PREFIX.length),
})
}
return line
})
.join("\n")
}
function localizeNotificationTitle(title: string, locale: ReturnType<typeof normalizeLocale>) {
switch (title.trim()) {
case TICKET_ASSIGNED_TITLE:
return translateMessage(locale, "notification.ticketAssignedTitle")
case CONVERSATION_TRANSFERRED_TITLE:
return translateMessage(locale, "notification.conversationTransferredTitle")
case CONVERSATION_AUTO_ASSIGNED_TITLE:
return translateMessage(locale, "notification.conversationAutoAssignedTitle")
case CONVERSATION_ASSIGNED_TITLE:
return translateMessage(locale, "notification.conversationAssignedTitle")
default:
return title
}
}
function splitNotificationLines(content: string) {
const normalized = content.trim()
if (!normalized) {
return []
}
return normalized.split("\n")
}

View File

@@ -0,0 +1,187 @@
import test from "node:test"
import assert from "node:assert/strict"
import {
updateOperatorPresenceList,
purgeExpiredOperatorPresences,
resolveOperatorPresence,
} from "./operator-presence-state.ts"
test("adds a new peer operator record with current timestamp", () => {
const currentList = []
const incoming = {
conversationId: 101,
userId: 2,
username: "alice",
nickname: "Alice Admin",
action: "viewing",
}
const now = 100_000
const result = updateOperatorPresenceList(currentList, incoming, 1, now)
assert.equal(result.length, 1)
assert.deepEqual(result[0], {
conversationId: 101,
userId: 2,
username: "alice",
nickname: "Alice Admin",
action: "viewing",
at: now,
})
})
test("ignores incoming events from the current user (self)", () => {
const currentList = [
{
conversationId: 101,
userId: 2,
username: "alice",
nickname: "Alice Admin",
action: "viewing",
at: 100_000,
},
]
const incoming = {
conversationId: 101,
userId: 1, // current user ID
username: "myself",
nickname: "Me",
action: "typing",
}
const result = updateOperatorPresenceList(currentList, incoming, 1, 101_000)
assert.equal(result, currentList)
})
test("updates action from viewing to typing and updates timestamp", () => {
const currentList = [
{
conversationId: 101,
userId: 2,
username: "alice",
nickname: "Alice Admin",
action: "viewing",
at: 100_000,
},
]
const incoming = {
conversationId: 101,
userId: 2,
username: "alice",
nickname: "Alice Admin",
action: "typing",
}
const now = 103_000
const result = updateOperatorPresenceList(currentList, incoming, 1, now)
assert.equal(result.length, 1)
assert.equal(result[0].action, "typing")
assert.equal(result[0].at, now)
})
test("removes operator record on action left", () => {
const currentList = [
{
conversationId: 101,
userId: 2,
username: "alice",
nickname: "Alice Admin",
action: "viewing",
at: 100_000,
},
{
conversationId: 101,
userId: 3,
username: "bob",
nickname: "Bob Operator",
action: "viewing",
at: 101_000,
},
]
const incoming = {
conversationId: 101,
userId: 2,
username: "alice",
nickname: "Alice Admin",
action: "left",
}
const now = 102_000
const result = updateOperatorPresenceList(currentList, incoming, 1, now)
assert.equal(result.length, 1)
assert.equal(result[0].userId, 3)
})
test("automatically filters out records older than 15 seconds during update", () => {
const currentList = [
{
conversationId: 101,
userId: 2,
username: "alice",
nickname: "Alice",
action: "viewing",
at: 80_000, // 25s old at now = 105_000 (> 15s)
},
{
conversationId: 101,
userId: 3,
username: "bob",
nickname: "Bob",
action: "viewing",
at: 95_000, // 10s old at now = 105_000 (valid)
},
]
const incoming = {
conversationId: 101,
userId: 4,
username: "charlie",
nickname: "Charlie",
action: "typing",
}
const now = 105_000
const result = updateOperatorPresenceList(currentList, incoming, 1, now)
assert.equal(result.length, 2)
const userIds = result.map((r) => r.userId)
assert.ok(!userIds.includes(2), "User 2 should be expired")
assert.ok(userIds.includes(3), "User 3 should be kept")
assert.ok(userIds.includes(4), "User 4 should be added")
})
test("purgeExpiredOperatorPresences filters out expired entries based on maxAgeMs", () => {
const list = [
{
conversationId: 101,
userId: 2,
username: "alice",
nickname: "Alice",
action: "viewing",
at: 84_000, // 16s ago at 100_000 -> expired
},
{
conversationId: 101,
userId: 3,
username: "bob",
nickname: "Bob",
action: "viewing",
at: 86_000, // 14s ago at 100_000 -> kept
},
]
const purged = purgeExpiredOperatorPresences(list, 100_000, 15_000)
assert.equal(purged.length, 1)
assert.equal(purged[0].userId, 3)
})
test("resolveOperatorPresence alias works identically to updateOperatorPresenceList", () => {
const currentList = []
const incoming = {
conversationId: 101,
userId: 2,
username: "alice",
nickname: "Alice",
action: "viewing",
}
const now = 50_000
const result = resolveOperatorPresence(currentList, incoming, 1, now)
assert.equal(result.length, 1)
assert.equal(result[0].userId, 2)
})

View File

@@ -0,0 +1,83 @@
export type OperatorPresenceAction = "viewing" | "typing" | "left"
export type OperatorPresenceRecord = {
conversationId: number
userId: number
username: string
nickname: string
action: OperatorPresenceAction
at: number
}
export type OperatorPresencePayload = {
conversationId?: number
userId?: number
username?: string
nickname?: string
action?: string
at?: string | number
}
const DEFAULT_PRESENCE_TTL_MS = 15_000
export function purgeExpiredOperatorPresences(
currentList: OperatorPresenceRecord[],
nowMs?: number,
maxAgeMs = DEFAULT_PRESENCE_TTL_MS
): OperatorPresenceRecord[] {
const now = nowMs ?? Date.now()
return (currentList || []).filter((record) => now - record.at <= maxAgeMs)
}
export function updateOperatorPresenceList(
currentList: OperatorPresenceRecord[],
incoming: OperatorPresencePayload,
currentUserId: number,
nowMs?: number
): OperatorPresenceRecord[] {
if (!incoming || !incoming.conversationId || !incoming.userId) {
return currentList
}
if (incoming.userId === currentUserId) {
return currentList
}
const now = nowMs ?? Date.now()
const list = currentList || []
if (incoming.action === "left") {
const nextList = list.filter(
(item) =>
!(item.conversationId === incoming.conversationId && item.userId === incoming.userId)
)
return purgeExpiredOperatorPresences(nextList, now)
}
if (incoming.action === "viewing" || incoming.action === "typing") {
const existingIndex = list.findIndex(
(item) =>
item.conversationId === incoming.conversationId && item.userId === incoming.userId
)
const record: OperatorPresenceRecord = {
conversationId: incoming.conversationId,
userId: incoming.userId,
username: incoming.username ?? "",
nickname: incoming.nickname ?? "",
action: incoming.action,
at: now,
}
let nextList: OperatorPresenceRecord[]
if (existingIndex >= 0) {
nextList = [...list]
nextList[existingIndex] = record
} else {
nextList = [...list, record]
}
return purgeExpiredOperatorPresences(nextList, now)
}
return purgeExpiredOperatorPresences(list, now)
}
export const resolveOperatorPresence = updateOperatorPresenceList

View File

@@ -0,0 +1,71 @@
import assert from "node:assert/strict"
import test from "node:test"
import ts from "typescript"
import { readFile } from "node:fs/promises"
import vm from "node:vm"
async function loadModule() {
const source = await readFile(new URL("./permission-i18n.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.CommonJS,
},
fileName: "permission-i18n.ts",
})
const sandbox = {
exports: {},
module: { exports: {} },
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return sandbox.module.exports
}
test("localizes seeded permission display names to English", async () => {
const { getPermissionDisplayName, getPermissionGroupName } = await loadModule()
assert.equal(getPermissionDisplayName("user.assignRole", "\u5206\u914d\u7528\u6237\u89d2\u8272", "en-US"), "Assign user roles")
assert.equal(getPermissionDisplayName("agentTeamSchedule.batchGenerate", "批量生成客服组排班", "en-US"), "Batch generate agent team schedules")
assert.equal(getPermissionDisplayName("branding.view", "查看平台品牌配置", "en-US"), "View branding settings")
assert.equal(getPermissionDisplayName("docPage.view", "查看文档中心页面", "en-US"), "View support docs")
assert.equal(getPermissionGroupName("agentTeamSchedule", "en-US"), "Agent team schedules")
assert.equal(getPermissionGroupName("branding", "en-US"), "Branding settings")
})
test("keeps original permission names for Chinese locale", async () => {
const { getPermissionDisplayName, getPermissionGroupName } = await loadModule()
assert.equal(getPermissionDisplayName("user.view", "查看用户", "zh-CN"), "查看用户")
assert.equal(getPermissionDisplayName("branding.view", "查看平台品牌配置", "zh-CN"), "查看平台品牌配置")
assert.equal(getPermissionGroupName("agentTeam", "zh-CN"), "agentTeam")
})
test("localizes seeded permission display names to Russian", async () => {
const { getPermissionDisplayName, getPermissionGroupName } = await loadModule()
assert.equal(
getPermissionDisplayName("user.assignRole", "分配用户角色", "ru-RU"),
"Назначение ролей пользователям"
)
assert.equal(getPermissionDisplayName("user.view", "查看用户", "ru-RU"), "Просмотр пользователей")
assert.equal(getPermissionDisplayName("branding.view", "查看平台品牌配置", "ru-RU"), "Просмотр настроек брендинга")
assert.equal(getPermissionDisplayName("docPage.view", "查看文档中心页面", "ru-RU"), "Просмотр страниц документации")
assert.equal(getPermissionDisplayName("supportConfig.update", "更新支持中心配置", "ru-RU"), "Изменение настроек центра поддержки")
assert.equal(
getPermissionDisplayName("agentTeamSchedule.batchGenerate", "批量生成客服组排班", "ru-RU"),
"Массовое создание расписаний команд операторов"
)
assert.equal(getPermissionGroupName("agentTeamSchedule", "ru-RU"), "Расписаний команд операторов")
assert.equal(getPermissionGroupName("aiAgent", "ru-RU"), "ИИ-агентов")
assert.equal(getPermissionGroupName("branding", "ru-RU"), "Настроек брендинга")
})
test("falls back for unknown codes and unsupported locales", async () => {
const { getPermissionDisplayName, getPermissionGroupName } = await loadModule()
assert.equal(getPermissionDisplayName("unknown.thing", "\u539f\u59cb\u540d\u79f0", "ru-RU"), "\u539f\u59cb\u540d\u79f0")
assert.equal(getPermissionDisplayName("user.view", "\u539f\u59cb\u540d\u79f0", "fr-FR"), "\u539f\u59cb\u540d\u79f0")
assert.equal(getPermissionGroupName("unknownGroup", "ru-RU"), "unknownGroup")
assert.equal(getPermissionGroupName("user", "fr-FR"), "user")
})

223
web/lib/permission-i18n.ts Normal file
View File

@@ -0,0 +1,223 @@
type PermissionVocabulary = {
actions: Record<string, string>
resources: Record<string, { singular: string; plural: string }>
nameOverrides: Record<string, string>
/** Labels that must not be re-capitalized, e.g. acronyms. */
preserveCase?: readonly string[]
}
const EN_PERMISSION_VOCABULARY: PermissionVocabulary = {
actions: {
view: "View",
create: "Create",
update: "Update",
delete: "Delete",
assignRole: "Assign roles to",
assignPermission: "Assign permissions to",
sync: "Sync",
revoke: "Revoke",
assign: "Assign",
transfer: "Transfer",
close: "Close",
send: "Send",
tag: "Manage tags for",
handover: "Handle handoffs for",
recycle: "Recycle",
linkCustomer: "Link customers to",
changeStatus: "Change status for",
progress: "Update progress for",
resetUserTokenSecret: "Reset user token secret for",
updateStatus: "Update status for",
config: "Configure service rules for",
batchGenerate: "Batch generate",
call: "Call",
},
resources: {
user: { singular: "user", plural: "users" },
role: { singular: "role", plural: "roles" },
permission: { singular: "permission", plural: "permissions" },
session: { singular: "session", plural: "sessions" },
conversation: { singular: "conversation", plural: "conversations" },
ticket: { singular: "ticket", plural: "tickets" },
notification: { singular: "notification", plural: "notifications" },
quickReply: { singular: "quick reply", plural: "quick replies" },
tag: { singular: "tag", plural: "tags" },
company: { singular: "company", plural: "companies" },
channel: { singular: "channel", plural: "channels" },
customer: { singular: "customer", plural: "customers" },
agent: { singular: "agent", plural: "agents" },
agentTeam: { singular: "agent team", plural: "agent teams" },
agentTeamSchedule: { singular: "agent team schedule", plural: "agent team schedules" },
asset: { singular: "file asset", plural: "file assets" },
aiAgent: { singular: "AI Agent", plural: "AI Agents" },
aiConfig: { singular: "AI configuration", plural: "AI configurations" },
knowledgeBase: { singular: "knowledge base", plural: "knowledge bases" },
knowledgeDocument: { singular: "knowledge document", plural: "knowledge documents" },
knowledgeFAQ: { singular: "knowledge FAQ", plural: "knowledge FAQs" },
skillDefinition: { singular: "Skill definition", plural: "Skill definitions" },
mcp: { singular: "MCP tool", plural: "MCP tools" },
docPage: { singular: "support doc", plural: "support docs" },
supportCommunity: { singular: "support community post", plural: "support community posts" },
supportConfig: { singular: "support center configuration", plural: "support center configurations" },
branding: { singular: "branding setting", plural: "branding settings" },
},
nameOverrides: {
"user.assignRole": "Assign user roles",
"role.assignPermission": "Assign role permissions",
"session.revoke": "Revoke sessions",
"conversation.linkCustomer": "Link conversation customer",
"ticket.changeStatus": "Change ticket status",
"ticket.progress": "Update ticket progress",
"channel.resetUserTokenSecret": "Reset channel user token secret",
"agent.config": "Configure agent service rules",
"agentTeamSchedule.batchGenerate": "Batch generate agent team schedules",
"mcp.view": "View MCP debug information",
"mcp.call": "Call MCP tools",
"docPage.view": "View support docs",
"docPage.create": "Create support docs",
"docPage.update": "Update support docs",
"docPage.delete": "Delete support docs",
"supportCommunity.view": "View support community",
"supportCommunity.update": "Moderate support community",
"supportConfig.view": "View support center configuration",
"supportConfig.update": "Update support center configuration",
"branding.view": "View branding settings",
"branding.update": "Update branding settings",
},
preserveCase: ["AI Agents", "MCP tools"],
}
/**
* Russian permission labels are built from genitive-plural resource names so
* the action noun and the resource agree grammatically.
*/
const RU_PERMISSION_VOCABULARY: PermissionVocabulary = {
actions: {
view: "Просмотр",
create: "Создание",
update: "Изменение",
delete: "Удаление",
assignRole: "Назначение ролей",
assignPermission: "Назначение разрешений",
sync: "Синхронизация",
revoke: "Отзыв",
assign: "Назначение",
transfer: "Передача",
close: "Закрытие",
send: "Отправка",
tag: "Управление тегами",
handover: "Обработка передач",
recycle: "Корзина",
linkCustomer: "Связывание клиентов",
changeStatus: "Изменение статуса",
progress: "Обновление хода работ",
resetUserTokenSecret: "Сброс секрета токена",
updateStatus: "Изменение статуса",
config: "Настройка правил обслуживания",
batchGenerate: "Массовое создание",
call: "Вызов",
},
resources: {
user: { singular: "пользователь", plural: "пользователей" },
role: { singular: "роль", plural: "ролей" },
permission: { singular: "разрешение", plural: "разрешений" },
session: { singular: "сессия", plural: "сессий" },
conversation: { singular: "диалог", plural: "диалогов" },
ticket: { singular: "обращение", plural: "обращений" },
notification: { singular: "уведомление", plural: "уведомлений" },
quickReply: { singular: "быстрый ответ", plural: "быстрых ответов" },
tag: { singular: "тег", plural: "тегов" },
company: { singular: "компания", plural: "компаний" },
channel: { singular: "канал", plural: "каналов" },
customer: { singular: "клиент", plural: "клиентов" },
agent: { singular: "оператор", plural: "операторов" },
agentTeam: { singular: "команда операторов", plural: "команд операторов" },
agentTeamSchedule: { singular: "расписание команды операторов", plural: "расписаний команд операторов" },
asset: { singular: "файл", plural: "файлов" },
aiAgent: { singular: "ИИ-агент", plural: "ИИ-агентов" },
aiConfig: { singular: "конфигурация ИИ", plural: "конфигураций ИИ" },
knowledgeBase: { singular: "база знаний", plural: "баз знаний" },
knowledgeDocument: { singular: "документ базы знаний", plural: "документов базы знаний" },
knowledgeFAQ: { singular: "вопрос FAQ", plural: "вопросов FAQ" },
skillDefinition: { singular: "определение навыка", plural: "определений навыков" },
mcp: { singular: "инструмент MCP", plural: "инструментов MCP" },
docPage: { singular: "страница документации", plural: "страниц документации" },
supportCommunity: { singular: "сообщество поддержки", plural: "сообществ поддержки" },
supportConfig: { singular: "настройка центра поддержки", plural: "настроек центра поддержки" },
branding: { singular: "настройка брендинга", plural: "настроек брендинга" },
},
nameOverrides: {
"user.assignRole": "Назначение ролей пользователям",
"role.assignPermission": "Назначение разрешений роли",
"session.revoke": "Отзыв сессий",
"conversation.linkCustomer": "Связывание клиента с диалогом",
"ticket.changeStatus": "Изменение статуса обращения",
"ticket.progress": "Обновление хода работ по обращению",
"channel.resetUserTokenSecret": "Сброс секрета токена пользователя канала",
"agent.config": "Настройка правил обслуживания оператора",
"agentTeamSchedule.batchGenerate": "Массовое создание расписаний команд операторов",
"mcp.view": "Просмотр отладочной информации MCP",
"mcp.call": "Вызов инструментов MCP",
"docPage.view": "Просмотр страниц документации",
"docPage.create": "Создание страниц документации",
"docPage.update": "Изменение страниц документации",
"docPage.delete": "Удаление страниц документации",
"supportCommunity.view": "Просмотр сообщества поддержки",
"supportCommunity.update": "Модерация сообщества поддержки",
"supportConfig.view": "Просмотр настроек центра поддержки",
"supportConfig.update": "Изменение настроек центра поддержки",
"branding.view": "Просмотр настроек брендинга",
"branding.update": "Изменение настроек брендинга",
},
}
const PERMISSION_VOCABULARIES: Record<string, PermissionVocabulary> = {
"en-US": EN_PERMISSION_VOCABULARY,
"ru-RU": RU_PERMISSION_VOCABULARY,
}
export function getPermissionDisplayName(
code: string | undefined,
fallbackName: string,
locale: string
) {
const vocabulary = PERMISSION_VOCABULARIES[locale]
if (!vocabulary) {
return fallbackName
}
const normalizedCode = code?.trim() ?? ""
if (!normalizedCode) {
return fallbackName
}
const override = vocabulary.nameOverrides[normalizedCode]
if (override) {
return override
}
const [resourceKey, actionKey] = normalizedCode.split(".")
const resource = vocabulary.resources[resourceKey]
const action = vocabulary.actions[actionKey]
if (!resource || !action) {
return fallbackName
}
return `${action} ${resource.plural}`
}
export function getPermissionGroupName(groupName: string | undefined, locale: string) {
const normalizedGroupName = groupName?.trim() ?? ""
const vocabulary = PERMISSION_VOCABULARIES[locale]
if (!vocabulary) {
return normalizedGroupName
}
const resource = vocabulary.resources[normalizedGroupName]
if (!resource) {
return normalizedGroupName
}
return sentenceCase(resource.plural, vocabulary.preserveCase)
}
function sentenceCase(value: string, preserveCase?: readonly string[]) {
if (preserveCase?.includes(value)) {
return value
}
return value.charAt(0).toUpperCase() + value.slice(1)
}

View File

@@ -0,0 +1,99 @@
import assert from "node:assert/strict"
import test from "node:test"
import { createRealtimeConnectionManager } from "./realtime-connection.ts"
// Minimal WebSocket mock for testing connection manager lifecycle
class MockWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
constructor() {
this.readyState = MockWebSocket.CONNECTING
this.listeners = new Map()
}
addEventListener(type, listener) {
if (!this.listeners.has(type)) {
this.listeners.set(type, [])
}
this.listeners.get(type).push(listener)
}
emit(type, event = {}) {
const list = this.listeners.get(type) ?? []
for (const listener of list) {
listener(event)
}
}
open() {
this.readyState = MockWebSocket.OPEN
this.emit("open")
}
close() {
this.readyState = MockWebSocket.CLOSED
this.emit("close", { code: 1000, reason: "Normal" })
}
send() {}
}
test("beforeConnect is called and awaited before createSocket", async () => {
let beforeConnectCalled = false
let socketCreated = false
let mockSocket = null
const manager = createRealtimeConnectionManager({
beforeConnect: async () => {
await new Promise((resolve) => setTimeout(resolve, 10))
beforeConnectCalled = true
},
createSocket: () => {
assert.ok(beforeConnectCalled, "expected beforeConnect to run before createSocket")
socketCreated = true
mockSocket = new MockWebSocket()
return mockSocket
},
})
manager.connect()
// Wait for beforeConnect promise to resolve
await new Promise((resolve) => setTimeout(resolve, 30))
assert.ok(beforeConnectCalled, "beforeConnect should be called")
assert.ok(socketCreated, "createSocket should be called")
assert.ok(mockSocket, "socket should be created")
manager.disconnect()
})
test("beforeConnect failure triggers onConnectError and disconnect status", async () => {
let connectError = null
let statusList = []
const manager = createRealtimeConnectionManager({
beforeConnect: async () => {
throw new Error("token expired")
},
createSocket: () => {
assert.fail("createSocket must not be called when beforeConnect fails")
},
onConnectError: (err) => {
connectError = err
},
onStatusChange: (status) => {
statusList.push(status)
},
})
manager.connect()
await new Promise((resolve) => setTimeout(resolve, 20))
assert.ok(connectError, "onConnectError should be called")
assert.equal(connectError.message, "token expired")
assert.ok(statusList.includes("disconnected"), "status should include disconnected")
manager.disconnect()
})

View File

@@ -0,0 +1,216 @@
"use client"
export type RealtimeConnectionStatus = "connecting" | "connected" | "disconnected"
type RealtimeConnectionManagerOptions = {
createSocket: () => WebSocket
beforeConnect?: () => Promise<unknown> | unknown
canReconnect?: () => boolean
onStatusChange?: (status: RealtimeConnectionStatus) => void
onSocketChange?: (socket: WebSocket | null) => void
onOpen?: (socket: WebSocket) => void
onMessage?: (event: MessageEvent, socket: WebSocket) => void
onClose?: (event: CloseEvent, socket: WebSocket) => void
onError?: (event: Event, socket: WebSocket) => void
onConnectError?: (error: unknown) => void
buildPingMessage?: () => string
pingIntervalMs?: number
reconnectBaseDelayMs?: number
reconnectMaxDelayMs?: number
}
type DisconnectOptions = {
reconnect?: boolean
updateStatus?: boolean
}
const DEFAULT_PING_INTERVAL_MS = 20000
const DEFAULT_RECONNECT_BASE_DELAY_MS = 2000
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000
export function createRealtimeConnectionManager(
options: RealtimeConnectionManagerOptions
) {
let socket: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let pingTimer: ReturnType<typeof setInterval> | null = null
let reconnectAttempt = 0
let reconnectEnabled = false
let connectEpoch = 0
const clearReconnectTimer = () => {
if (reconnectTimer !== null) {
globalThis.clearTimeout(reconnectTimer)
reconnectTimer = null
}
}
const clearPingTimer = () => {
if (pingTimer !== null) {
globalThis.clearInterval(pingTimer)
pingTimer = null
}
}
const clearTimers = () => {
clearReconnectTimer()
clearPingTimer()
}
const setSocket = (nextSocket: WebSocket | null) => {
socket = nextSocket
options.onSocketChange?.(nextSocket)
}
const canReconnect = () => {
return reconnectEnabled && (options.canReconnect?.() ?? true)
}
const scheduleReconnect = () => {
if (!canReconnect() || reconnectTimer !== null) {
return
}
const delay = Math.min(
(options.reconnectBaseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS) *
2 ** reconnectAttempt,
options.reconnectMaxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS
)
options.onStatusChange?.("connecting")
reconnectTimer = globalThis.setTimeout(() => {
reconnectTimer = null
reconnectAttempt += 1
if (canReconnect()) {
void connect()
}
}, delay)
}
const connect = async () => {
reconnectEnabled = true
if (!(options.canReconnect?.() ?? true)) {
return
}
disconnect({ reconnect: true, updateStatus: false })
reconnectEnabled = true
options.onStatusChange?.("connecting")
const currentEpoch = ++connectEpoch
if (options.beforeConnect) {
try {
await options.beforeConnect()
} catch (error) {
if (currentEpoch !== connectEpoch || !reconnectEnabled) {
return
}
options.onStatusChange?.("disconnected")
options.onConnectError?.(error)
scheduleReconnect()
return
}
}
if (
currentEpoch !== connectEpoch ||
!reconnectEnabled ||
!(options.canReconnect?.() ?? true)
) {
return
}
let nextSocket: WebSocket
try {
nextSocket = options.createSocket()
} catch (error) {
options.onStatusChange?.("disconnected")
options.onConnectError?.(error)
scheduleReconnect()
return
}
setSocket(nextSocket)
nextSocket.addEventListener("open", () => {
if (socket !== nextSocket) {
return
}
clearReconnectTimer()
clearPingTimer()
reconnectAttempt = 0
options.onStatusChange?.("connected")
const buildPingMessage =
options.buildPingMessage ?? (() => JSON.stringify({ type: "ping" }))
pingTimer = globalThis.setInterval(() => {
if (nextSocket.readyState === WebSocket.OPEN) {
nextSocket.send(buildPingMessage())
}
}, options.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS)
options.onOpen?.(nextSocket)
})
nextSocket.addEventListener("message", (event) => {
if (socket === nextSocket) {
options.onMessage?.(event, nextSocket)
}
})
nextSocket.addEventListener("close", (event) => {
clearPingTimer()
const isCurrentSocket = socket === nextSocket
if (isCurrentSocket) {
setSocket(null)
}
options.onClose?.(event, nextSocket)
if (!isCurrentSocket) {
return
}
if (canReconnect()) {
scheduleReconnect()
} else {
options.onStatusChange?.("disconnected")
}
})
nextSocket.addEventListener("error", (event) => {
if (socket !== nextSocket) {
return
}
options.onError?.(event, nextSocket)
options.onStatusChange?.("disconnected")
scheduleReconnect()
})
}
const disconnect = (disconnectOptions?: DisconnectOptions) => {
reconnectEnabled = disconnectOptions?.reconnect ?? false
clearTimers()
if (!reconnectEnabled) {
reconnectAttempt = 0
}
const currentSocket = socket
setSocket(null)
if (
currentSocket &&
(currentSocket.readyState === WebSocket.OPEN ||
currentSocket.readyState === WebSocket.CONNECTING)
) {
currentSocket.close()
}
if (disconnectOptions?.updateStatus ?? !reconnectEnabled) {
options.onStatusChange?.("disconnected")
}
}
return {
connect,
disconnect,
reconnect: () => {
reconnectEnabled = true
connect()
},
getSocket: () => socket,
}
}

View File

@@ -0,0 +1,54 @@
import assert from "node:assert/strict"
import test from "node:test"
import ts from "typescript"
import { readFile } from "node:fs/promises"
import vm from "node:vm"
async function loadModule() {
const source = await readFile(new URL("./role-i18n.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.CommonJS,
},
fileName: "role-i18n.ts",
})
const sandbox = {
exports: {},
module: { exports: {} },
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return sandbox.module.exports
}
test("localizes seeded role names to English by role code", async () => {
const { getRoleDisplayName } = await loadModule()
assert.equal(getRoleDisplayName("super_admin", "\u8d85\u7ea7\u7ba1\u7406\u5458", "en-US"), "Super admin")
assert.equal(getRoleDisplayName("cs_team_leader", "\u5ba2\u670d\u7ec4\u957f", "en-US"), "Support team lead")
assert.equal(getRoleDisplayName("cs_user", "\u5ba2\u670d", "en-US"), "Support agent")
})
test("keeps custom or Chinese role names unchanged", async () => {
const { getRoleDisplayName } = await loadModule()
assert.equal(getRoleDisplayName("custom", "Ops reviewer", "en-US"), "Ops reviewer")
assert.equal(getRoleDisplayName("super_admin", "\u8d85\u7ea7\u7ba1\u7406\u5458", "zh-CN"), "\u8d85\u7ea7\u7ba1\u7406\u5458")
})
test("localizes seeded role names to Russian by role code", async () => {
const { getRoleDisplayName } = await loadModule()
assert.equal(
getRoleDisplayName("super_admin", "\u8d85\u7ea7\u7ba1\u7406\u5458", "ru-RU"),
"\u0421\u0443\u043f\u0435\u0440\u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440"
)
assert.equal(
getRoleDisplayName("cs_team_leader", "\u5ba2\u670d\u7ec4\u957f", "ru-RU"),
"\u0420\u0443\u043a\u043e\u0432\u043e\u0434\u0438\u0442\u0435\u043b\u044c \u043a\u043e\u043c\u0430\u043d\u0434\u044b \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0438"
)
assert.equal(getRoleDisplayName("cs_user", "\u5ba2\u670d", "ru-RU"), "\u041e\u043f\u0435\u0440\u0430\u0442\u043e\u0440 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0438")
assert.equal(getRoleDisplayName("custom", "Ops reviewer", "ru-RU"), "Ops reviewer")
assert.equal(getRoleDisplayName("super_admin", "\u8d85\u7ea7\u7ba1\u7406\u5458", "fr-FR"), "\u8d85\u7ea7\u7ba1\u7406\u5458")
})

27
web/lib/role-i18n.ts Normal file
View File

@@ -0,0 +1,27 @@
const SEEDED_ROLE_LABELS: Record<string, Record<string, string>> = {
"en-US": {
super_admin: "Super admin",
admin: "Admin",
cs_team_leader: "Support team lead",
cs_user: "Support agent",
},
"ru-RU": {
super_admin: "Суперадминистратор",
admin: "Администратор",
cs_team_leader: "Руководитель команды поддержки",
cs_user: "Оператор поддержки",
},
}
export function getRoleDisplayName(
code: string | undefined,
fallbackName: string,
locale: string
) {
const labels = SEEDED_ROLE_LABELS[locale]
if (!labels) {
return fallbackName
}
const label = labels[code?.trim() ?? ""]
return label || fallbackName
}

View File

@@ -0,0 +1,305 @@
import assert from "node:assert/strict"
import { readFile } from "node:fs/promises"
import test from "node:test"
import ts from "typescript"
import vm from "node:vm"
function createElement(tagName) {
return {
tagName,
children: [],
dataset: {},
style: {},
attributes: {},
listeners: {},
parentNode: null,
contentWindow: {},
setAttribute(name, value) {
this.attributes[name] = String(value)
},
appendChild(child) {
child.parentNode = this
this.children.push(child)
return child
},
removeChild(child) {
this.children = this.children.filter((item) => item !== child)
child.parentNode = null
return child
},
addEventListener(type, handler) {
this.listeners[type] = handler
},
click() {
this.listeners.click?.()
},
}
}
async function loadSdk(config) {
const source = await readFile(new URL("./agent-desk-sdk.ts", import.meta.url), "utf8")
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.ESNext,
importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove,
},
fileName: "agent-desk-sdk.ts",
})
const body = createElement("body")
const sandbox = {
URL,
console,
fetch: async (url) => ({
json: async () =>
String(url).endsWith("/api/config")
? {
success: true,
data: {
language: "en-US",
},
}
: {
success: true,
data: {
themeColor: "#2563eb",
},
},
}),
document: {
body,
currentScript: {
src: "https://chat.example/sdk/agent-desk-sdk.min.js",
},
createElement,
createElementNS: (_namespace, tagName) => createElement(tagName),
},
window: {
AgentDeskConfig: config,
location: {
origin: "https://host.example",
},
addEventListener() {},
removeEventListener() {},
clearTimeout() {},
setTimeout(handler, delay) {
if (!delay || delay < 1000) {
handler()
}
return 1
},
},
}
sandbox.window.window = sandbox.window
sandbox.window.document = sandbox.document
sandbox.window.fetch = sandbox.fetch
sandbox.window.console = console
sandbox.window.URL = URL
sandbox.window.setTimeout = sandbox.window.setTimeout
sandbox.window.clearTimeout = sandbox.window.clearTimeout
const compiledCode = compiled.outputText.replace(/\nexport\s*\{\};?\s*$/, "")
vm.runInNewContext(compiledCode, sandbox)
await Promise.resolve()
await Promise.resolve()
return sandbox
}
async function flushPromises(count = 30) {
for (let i = 0; i < count; i += 1) {
await Promise.resolve()
}
}
test("getChatUrl resolves a fresh userToken for each call", async () => {
let calls = 0
const sandbox = await loadSdk({
channelId: "ch_1",
baseUrl: "https://api.example",
getUserToken: async () => `token_${++calls}`,
})
assert.equal(typeof sandbox.window.AgentDeskWidget.getChatUrl, "function")
const first = await sandbox.window.AgentDeskWidget.getChatUrl()
const second = await sandbox.window.AgentDeskWidget.getChatUrl()
assert.equal(new URL(first).pathname, "/support/chat/")
assert.equal(new URL(second).pathname, "/support/chat/")
assert.equal(new URL(first).searchParams.get("userToken"), "token_1")
assert.equal(new URL(second).searchParams.get("userToken"), "token_2")
assert.equal(new URL(first).searchParams.get("parentOrigin"), "https://host.example")
assert.equal(calls, 2)
})
test("getChatUrl forwards a signed externalId with its timestamp", async () => {
const seen = []
const sandbox = await loadSdk({
channelId: "ch_1",
baseUrl: "https://api.example",
externalId: "u_10001",
externalName: "Ivan",
signExternalId: async (externalId) => {
seen.push(externalId)
return { signature: `sig_${seen.length}`, issuedAt: 1700000000 + seen.length }
},
})
const first = await sandbox.window.AgentDeskWidget.getChatUrl()
const second = await sandbox.window.AgentDeskWidget.getChatUrl()
assert.equal(new URL(first).searchParams.get("externalId"), "u_10001")
assert.equal(new URL(first).searchParams.get("externalIdSignature"), "sig_1")
assert.equal(new URL(first).searchParams.get("externalIdSignedAt"), "1700000001")
assert.equal(new URL(second).searchParams.get("externalIdSignature"), "sig_2")
assert.equal(new URL(second).searchParams.get("externalIdSignedAt"), "1700000002")
assert.deepEqual(seen, ["u_10001", "u_10001"])
})
test("no signer means no identity parameters", async () => {
const sandbox = await loadSdk({
channelId: "ch_1",
baseUrl: "https://api.example",
externalId: "u_10001",
})
const url = await sandbox.window.AgentDeskWidget.getChatUrl()
assert.equal(new URL(url).searchParams.get("externalId"), "u_10001")
assert.equal(new URL(url).searchParams.has("externalIdSignature"), false)
assert.equal(new URL(url).searchParams.has("externalIdSignedAt"), false)
})
test("signer without an externalId is not called", async () => {
let calls = 0
const sandbox = await loadSdk({
channelId: "ch_1",
baseUrl: "https://api.example",
signExternalId: async () => {
calls += 1
return { signature: "sig", issuedAt: 1700000000 }
},
})
const url = await sandbox.window.AgentDeskWidget.getChatUrl()
assert.equal(calls, 0)
assert.equal(new URL(url).searchParams.has("externalIdSignature"), false)
})
// A signature expires, so reopening on a long-lived host page must hand the frame
// a fresh one instead of leaving the stale pair in its URL.
test("reopening reloads the frame when a fresh signature was issued", async () => {
let issued = 0
const sandbox = await loadSdk({
channelId: "ch_1",
baseUrl: "https://api.example",
externalId: "u_10001",
signExternalId: async () => {
issued += 1
return { signature: `sig_${issued}`, issuedAt: 1700000000 + issued }
},
})
await flushPromises()
const widget = sandbox.window.AgentDeskWidget
await widget.open()
const frame = sandbox.document.body.children.find(
(child) => child.dataset.agentDeskWidget === "frame"
)
assert.ok(frame, "expected the launcher click to create the frame")
const firstSrc = frame.src
assert.ok(firstSrc.includes("externalIdSignature=sig_1"), `frame src = ${firstSrc}`)
widget.close()
await widget.open()
const reloaded = sandbox.document.body.children.find(
(child) => child.dataset.agentDeskWidget === "frame"
)
assert.ok(reloaded.src.includes("externalIdSignature=sig_2"), `frame src after reopen = ${reloaded.src}`)
assert.notEqual(reloaded.src, firstSrc)
})
test("launcher click creates chat iframe with a freshly resolved userToken", async () => {
const sandbox = await loadSdk({
channelId: "ch_1",
baseUrl: "https://api.example",
getUserToken: async () => "click_token",
})
await flushPromises()
const launcher = sandbox.document.body.children.find(
(child) => child.dataset.agentDeskWidget === "launcher"
)
assert.ok(launcher)
assert.equal(launcher.children.at(-1)?.textContent, "Support")
launcher.click()
await flushPromises()
const frame = sandbox.document.body.children.find(
(child) => child.dataset.agentDeskWidget === "frame"
)
assert.ok(frame)
assert.equal(new URL(frame.src).pathname, "/support/chat/")
assert.equal(new URL(frame.src).searchParams.get("userToken"), "click_token")
})
test("requireUserToken rejects opening when getUserToken is missing or empty", async () => {
const sandbox = await loadSdk({
channelId: "ch_1",
baseUrl: "https://api.example",
requireUserToken: true,
getUserToken: async () => " ",
})
await flushPromises()
await assert.rejects(
() => sandbox.window.AgentDeskWidget.open(),
/Support user token is required/
)
const frame = sandbox.document.body.children.find(
(child) => child.dataset.agentDeskWidget === "frame"
)
assert.equal(frame, undefined)
})
test("ru-RU language configures Russian launcher and default title", async () => {
const sandbox = await loadSdk({
channelId: "ch_1",
baseUrl: "https://api.example",
language: "ru-RU",
})
await flushPromises()
const launcher = sandbox.document.body.children.find(
(child) => child.dataset.agentDeskWidget === "launcher"
)
assert.ok(launcher)
assert.equal(launcher.attributes["aria-label"], "Поддержка")
assert.equal(launcher.children.at(-1)?.textContent, "Чат")
})
test("chat iframe layout uses dynamic viewport height for iOS browser chrome", async () => {
const sandbox = await loadSdk({
channelId: "ch_1",
baseUrl: "https://api.example",
})
await flushPromises()
const launcher = sandbox.document.body.children.find(
(child) => child.dataset.agentDeskWidget === "launcher"
)
launcher.click()
await flushPromises()
const frame = sandbox.document.body.children.find(
(child) => child.dataset.agentDeskWidget === "frame"
)
assert.ok(frame)
assert.match(frame.style.height, /100dvh/)
assert.match(frame.style.maxWidth, /100vw/)
assert.equal(frame.style.borderRadius, "16px")
})

View File

@@ -0,0 +1,772 @@
import type {
AgentDeskConfig,
AgentDeskWidget,
SupportChatRuntimeConfig,
} from "./config-types"
type NormalizedAgentDeskConfig = AgentDeskConfig & {
baseUrl: string
channelId: string
language: string
position: "left" | "right"
themeColor: string
width: string
}
/** A host-signed visitor identity, resolved fresh on every open. */
type ResolvedExternalIdentity = {
signature: string
issuedAt: number
}
type WidgetState = {
button: HTMLButtonElement | null
frame: HTMLIFrameElement | null
frameLoaded: boolean
frameReady: boolean
initSent: boolean
isOpen: boolean
opening: boolean
isMaximized: boolean
mounted: boolean
mountGeneration: number
openGeneration: number
cancelOpen: (() => void) | null
configLoading: boolean
configPromise: Promise<void> | null
frameHideTimer: number | null
config: NormalizedAgentDeskConfig | null
frameConfig: SupportChatRuntimeConfig | null
frameUrl: URL | null
animationDuration: number
messageListener: ((event: MessageEvent) => void) | null
resizeListener: (() => void) | null
}
type WidgetConfigResponse = {
success?: boolean
data?: Partial<Pick<
AgentDeskConfig,
"title" | "subtitle" | "themeColor" | "position" | "width"
>>
}
type PublicConfigResponse = {
success?: boolean
data?: {
language?: string
}
}
function normalizeWidgetLanguage(language: string | undefined) {
const lang = String(language || "").toLowerCase()
if (lang.startsWith("ru")) return "ru-RU"
if (lang.startsWith("en")) return "en-US"
return "zh-CN"
}
function getDefaultWidgetTitle(config?: NormalizedAgentDeskConfig | null) {
const lang = normalizeWidgetLanguage(config?.language)
if (lang === "ru-RU") return "Поддержка"
if (lang === "en-US") return "Support"
return "\u5728\u7ebf\u5ba2\u670d"
}
function getLauncherText(config?: NormalizedAgentDeskConfig | null) {
const lang = normalizeWidgetLanguage(config?.language)
if (lang === "ru-RU") return "Чат"
if (lang === "en-US") return "Support"
return "\u5ba2\u670d"
}
type FrameMessage =
| { type: "agent-desk:init"; payload: SupportChatRuntimeConfig }
| { type: "agent-desk:open" }
| { type: "agent-desk:minimize" }
| { type: "agent-desk:maximized"; payload: { isMaximized: boolean } }
;(function () {
const DEFAULT_CONFIG: Pick<
NormalizedAgentDeskConfig,
"language" | "position" | "themeColor" | "width"
> = {
language: "zh-CN",
position: "right",
themeColor: "#0f6cbd",
width: "380px",
}
// Capture the SDK origin while currentScript still refers to this script.
const currentScript = document.currentScript as HTMLScriptElement | null
const scriptBaseUrl = currentScript?.src
? currentScript.src.replace(/\/sdk\/agent-desk-sdk\.min\.js(?:\?.*)?$/, "")
: ""
const existingState = window.__CS_AI_AGENT_WIDGET_STATE__ as WidgetState | undefined
// Re-evaluating the script must not leave callbacks from an older instance.
if (existingState) window.AgentDeskWidget?.destroy()
const state: WidgetState =
existingState || {
button: null,
frame: null,
frameLoaded: false,
frameReady: false,
initSent: false,
isOpen: false,
opening: false,
isMaximized: false,
mounted: false,
mountGeneration: 0,
openGeneration: 0,
cancelOpen: null,
configLoading: false,
configPromise: null,
frameHideTimer: null,
config: null,
frameConfig: null,
frameUrl: null,
animationDuration: 260,
messageListener: null,
resizeListener: null,
}
if (!existingState) {
window.__CS_AI_AGENT_WIDGET_STATE__ = state
}
function normalizeConfig(config?: AgentDeskConfig): NormalizedAgentDeskConfig {
const merged: Record<string, unknown> = { ...DEFAULT_CONFIG, ...(config || {}) }
merged.baseUrl = String(merged.baseUrl || window.location.origin).replace(/\/$/, "")
if (merged.apiBaseUrl) {
merged.apiBaseUrl = String(merged.apiBaseUrl).replace(/\/$/, "")
} else {
delete merged.apiBaseUrl
}
merged.channelId = String(merged.channelId || "")
merged.language = normalizeWidgetLanguage(String(merged.language || "zh-CN"))
if (merged.externalId) {
merged.externalId = String(merged.externalId)
}
if (typeof merged.getUserToken !== "function") {
delete merged.getUserToken
}
return merged as NormalizedAgentDeskConfig
}
function resolveWidgetBaseUrl(config: NormalizedAgentDeskConfig) {
return String(config.widgetBaseUrl || scriptBaseUrl || config.baseUrl || window.location.origin).replace(/\/$/, "")
}
function resolveConfig(config: AgentDeskConfig) {
const normalized = normalizeConfig(config)
normalized.widgetBaseUrl = resolveWidgetBaseUrl(normalized)
if (!config.baseUrl) normalized.baseUrl = normalized.widgetBaseUrl
return normalized
}
function createFrameUrl(
config: NormalizedAgentDeskConfig,
userToken: string,
identity: ResolvedExternalIdentity
) {
const widgetBaseUrl = resolveWidgetBaseUrl(config)
const frameUrl = new URL(`${widgetBaseUrl}/support/chat/`)
frameUrl.searchParams.set("channelId", config.channelId)
frameUrl.searchParams.set("baseUrl", config.baseUrl)
frameUrl.searchParams.set("parentOrigin", window.location.origin)
if (config.requireUserToken) frameUrl.searchParams.set("requireUserToken", "true")
if (config.apiBaseUrl) frameUrl.searchParams.set("apiBaseUrl", config.apiBaseUrl)
if (config.externalId) frameUrl.searchParams.set("externalId", config.externalId)
if (identity.signature) frameUrl.searchParams.set("externalIdSignature", identity.signature)
if (identity.issuedAt) frameUrl.searchParams.set("externalIdSignedAt", String(identity.issuedAt))
if (config.externalName) frameUrl.searchParams.set("externalName", config.externalName)
if (userToken) frameUrl.searchParams.set("userToken", userToken)
return frameUrl
}
function createFrameConfig(
config: NormalizedAgentDeskConfig,
userToken: string,
identity: ResolvedExternalIdentity
): SupportChatRuntimeConfig {
const payload = { ...config } as Record<string, unknown>
// Functions cannot cross the postMessage boundary; their resolved values travel instead.
delete payload.getUserToken
delete payload.signExternalId
if (userToken) {
return { ...payload, userToken } as SupportChatRuntimeConfig
}
if (identity.signature) {
return {
...payload,
externalIdSignature: identity.signature,
externalIdSignedAt: identity.issuedAt,
} as SupportChatRuntimeConfig
}
return payload as SupportChatRuntimeConfig
}
function resolveUserToken(config: NormalizedAgentDeskConfig) {
if (typeof config?.getUserToken !== "function") {
if (config.requireUserToken) return Promise.reject(new Error("Support user token is required"))
return Promise.resolve("")
}
try {
return Promise.resolve(config.getUserToken()).then((token) => {
const value = String(token || "").trim()
if (config.requireUserToken && !value) throw new Error("Support user token is required")
return value
})
} catch (error) {
return Promise.reject(error)
}
}
// Resolved per open(), like getUserToken, so the pair is always fresh and a
// long-lived host page does not keep replaying an expired signature. A missing
// signer simply means the visitor stays an anonymous guest.
function resolveExternalIdentity(config: NormalizedAgentDeskConfig): Promise<ResolvedExternalIdentity> {
if (!config?.externalId || typeof config.signExternalId !== "function") {
return Promise.resolve({ signature: "", issuedAt: 0 })
}
try {
return Promise.resolve(config.signExternalId(config.externalId)).then((signed) => ({
signature: String(signed?.signature || "").trim(),
issuedAt: Number(signed?.issuedAt) || 0,
}))
} catch (error) {
return Promise.reject(error)
}
}
function prepareFrame(config: NormalizedAgentDeskConfig) {
// Resolve against one snapshot. A later mount must never receive a token or
// signature requested for the previous channel or visitor.
return Promise.all([resolveUserToken(config), resolveExternalIdentity(config)]).then(
([userToken, identity]) => ({
frameUrl: createFrameUrl(config, userToken, identity),
frameConfig: createFrameConfig(config, userToken, identity),
})
)
}
function mergeWidgetConfig(
config: NormalizedAgentDeskConfig,
remoteConfig?: WidgetConfigResponse["data"]
) {
if (!remoteConfig) {
return config
}
const merged: NormalizedAgentDeskConfig = { ...config }
const remoteKeys = ["title", "subtitle", "themeColor", "position", "width"] as const
remoteKeys.forEach((key) => {
const value = remoteConfig[key]
if (value !== undefined && value !== null) {
;(merged[key] as typeof value) = value
}
})
return merged
}
function fetchConfigPayload<T>(url: string, options: RequestInit): Promise<T> {
const controller = typeof AbortController === "function" ? new AbortController() : null
// Remote defaults are optional. A slow config service must not prevent the
// host from opening with its own configuration indefinitely.
return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(() => {
controller?.abort()
reject(new Error("Widget configuration request timed out"))
}, 5000)
Promise.resolve()
.then(() => fetch(url, { ...options, signal: controller?.signal }))
.then((response) => response.json() as Promise<T>)
.then(
(payload) => {
window.clearTimeout(timer)
resolve(payload)
},
(error) => {
window.clearTimeout(timer)
reject(error)
}
)
})
}
function fetchWidgetConfig(config: NormalizedAgentDeskConfig) {
const baseUrl = String(config.apiBaseUrl || config.baseUrl || "").replace(/\/$/, "")
if (!baseUrl || !config.channelId || typeof fetch !== "function") {
return Promise.resolve(config)
}
const url = `${baseUrl}/api/channel/config?channelId=${encodeURIComponent(config.channelId)}`
return fetchConfigPayload<WidgetConfigResponse>(url, {
method: "GET",
cache: "no-store",
headers: {
"X-Channel-Id": config.channelId,
},
})
.then((payload) => {
if (!payload || payload.success === false) {
return config
}
return mergeWidgetConfig(config, payload.data || {})
})
.catch(() => config)
}
function fetchPublicConfig(config: NormalizedAgentDeskConfig) {
const baseUrl = String(config.apiBaseUrl || config.baseUrl || "").replace(/\/$/, "")
if (!baseUrl || typeof fetch !== "function") {
return Promise.resolve(config)
}
return fetchConfigPayload<PublicConfigResponse>(`${baseUrl}/api/config`, {
method: "GET",
cache: "no-store",
})
.then((payload) => {
if (!payload || payload.success === false) {
return config
}
return normalizeConfig({
...config,
language: payload.data?.language || config.language,
})
})
.catch(() => config)
}
function clearFrameTimers() {
if (state.frameHideTimer !== null) {
window.clearTimeout(state.frameHideTimer)
state.frameHideTimer = null
}
}
function applyFrameLayout() {
const frame = state.frame
const config = state.config
if (!frame || !config) {
return
}
frame.style.position = "fixed"
frame.style.border = "0"
frame.style.overflow = "hidden"
frame.style.background = "#fff"
frame.style.zIndex = "2147483000"
frame.style.boxShadow =
"0 0 0 1px rgba(15, 35, 65, 0.06), 0 4px 12px rgba(15, 35, 65, 0.08), 0 24px 64px rgba(15, 35, 65, 0.22)"
frame.style.willChange = "top,right,bottom,left,width,height,opacity,transform,border-radius"
frame.style.transition =
"top 260ms cubic-bezier(0.22, 1, 0.36, 1), right 260ms cubic-bezier(0.22, 1, 0.36, 1), bottom 260ms cubic-bezier(0.22, 1, 0.36, 1), left 260ms cubic-bezier(0.22, 1, 0.36, 1), width 260ms cubic-bezier(0.22, 1, 0.36, 1), height 260ms cubic-bezier(0.22, 1, 0.36, 1), opacity 220ms ease, transform 260ms cubic-bezier(0.22, 1, 0.36, 1), border-radius 260ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 260ms ease"
frame.style.transformOrigin =
config.position === "left" ? "left bottom" : "right bottom"
// Phones get the full viewport instead of a floating card that squeezes the chat.
const isCompact =
typeof window.innerWidth === "number" && window.innerWidth > 0 && window.innerWidth <= 480
if (state.isMaximized || isCompact) {
frame.style.top = "max(12px, env(safe-area-inset-top))"
frame.style.right = "max(12px, env(safe-area-inset-right))"
frame.style.bottom = "max(12px, env(safe-area-inset-bottom))"
frame.style.left = "max(12px, env(safe-area-inset-left))"
frame.style.width =
"calc(100vw - max(12px, env(safe-area-inset-left)) - max(12px, env(safe-area-inset-right)))"
frame.style.maxWidth = "none"
frame.style.height =
"calc(100dvh - max(12px, env(safe-area-inset-top)) - max(12px, env(safe-area-inset-bottom)))"
frame.style.borderRadius = "16px"
return
}
frame.style.top = ""
frame.style.bottom = "max(88px, calc(84px + env(safe-area-inset-bottom)))"
frame.style.right = config.position === "left" ? "" : "max(12px, env(safe-area-inset-right))"
frame.style.left = config.position === "left" ? "max(12px, env(safe-area-inset-left))" : ""
frame.style.width = config.width || "380px"
frame.style.maxWidth =
"calc(100vw - max(12px, env(safe-area-inset-left)) - max(12px, env(safe-area-inset-right)))"
frame.style.height =
"min(760px, calc(100dvh - max(104px, calc(88px + env(safe-area-inset-bottom))) - max(12px, env(safe-area-inset-top))))"
frame.style.borderRadius = "16px"
}
function postToFrame(message: FrameMessage) {
if (!state.frame?.contentWindow || !state.frameUrl) {
return
}
try {
state.frame.contentWindow.postMessage(message, state.frameUrl.origin)
} catch (error) {
console.error("[agent-desk-widget] postMessage failed", error)
}
}
function flushFrameState() {
if (!state.frame || !state.frameLoaded || !state.frameReady || !state.frameConfig) {
return
}
if (!state.initSent) {
state.initSent = true
postToFrame({
type: "agent-desk:init",
payload: state.frameConfig,
})
}
postToFrame({ type: state.isOpen ? "agent-desk:open" : "agent-desk:minimize" })
postToFrame({
type: "agent-desk:maximized",
payload: { isMaximized: state.isMaximized },
})
}
function syncLauncherState() {
state.button?.setAttribute("aria-expanded", String(state.isOpen || state.opening))
state.button?.setAttribute("aria-busy", String(state.opening))
}
function syncFrameVisibility() {
syncLauncherState()
const frame = state.frame
if (!frame) {
return
}
clearFrameTimers()
applyFrameLayout()
frame.style.display = "block"
if (state.isOpen) {
frame.style.visibility = "visible"
frame.style.pointerEvents = "auto"
state.frameHideTimer = window.setTimeout(() => {
if (state.frame !== frame || !state.isOpen || !state.mounted) {
return
}
frame.style.opacity = "1"
frame.style.transform = "translate3d(0, 0, 0) scale(1)"
}, 16)
flushFrameState()
return
}
frame.style.pointerEvents = "none"
frame.style.opacity = "0"
frame.style.transform = state.isMaximized
? "translate3d(0, 10px, 0) scale(0.985)"
: "translate3d(0, 16px, 0) scale(0.96)"
state.frameHideTimer = window.setTimeout(() => {
if (state.frame !== frame || state.isOpen || !state.mounted) {
return
}
frame.style.visibility = "hidden"
}, state.animationDuration)
flushFrameState()
}
function removeFrame() {
clearFrameTimers()
if (state.frame?.parentNode) state.frame.parentNode.removeChild(state.frame)
state.frame = null
state.frameLoaded = false
state.frameReady = false
state.initSent = false
}
function createFrame() {
if (state.frame) {
return state.frame
}
if (!state.frameUrl || !state.config || !state.mounted) {
return null
}
const frame = document.createElement("iframe")
state.frame = frame
frame.dataset.agentDeskWidget = "frame"
frame.title = state.config.title || getDefaultWidgetTitle(state.config)
frame.src = state.frameUrl.toString()
applyFrameLayout()
frame.style.display = "block"
frame.style.visibility = "hidden"
frame.style.pointerEvents = "none"
frame.style.opacity = "0"
frame.style.transform = "translate3d(0, 18px, 0) scale(0.96)"
frame.addEventListener("load", () => {
if (state.frame !== frame || !state.mounted) return
state.frameLoaded = true
syncFrameVisibility()
})
document.body.appendChild(frame)
return frame
}
function handleWindowMessage(event: MessageEvent) {
if (
!state.mounted ||
!state.frame ||
!state.frameUrl ||
event.source !== state.frame.contentWindow ||
event.origin !== state.frameUrl.origin
) {
return
}
if (!event.data || typeof event.data !== "object") return
const data = event.data as { type?: string }
if (data.type === "agent-desk:ready") {
state.frameReady = true
flushFrameState()
return
}
if (data.type === "agent-desk:request-minimize" || data.type === "agent-desk:request-close") {
closeWidget()
return
}
if (data.type === "agent-desk:request-toggle-maximize") {
state.isMaximized = !state.isMaximized
syncFrameVisibility()
}
}
function createLauncher() {
if (state.button) {
return state.button
}
const config = state.config
if (!config) {
return null
}
const button = document.createElement("button")
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg")
const iconPaths = ["M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"]
const text = document.createElement("span")
const themeColor = config.themeColor || "#0f6cbd"
const reduceMotion =
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
const restShadow = "0 1px 2px rgba(15, 35, 65, 0.16), 0 8px 24px rgba(15, 35, 65, 0.22)"
const hoverShadow = "0 2px 4px rgba(15, 35, 65, 0.18), 0 12px 32px rgba(15, 35, 65, 0.28)"
button.type = "button"
button.dataset.agentDeskWidget = "launcher"
button.setAttribute("aria-label", config.title || getDefaultWidgetTitle(config))
icon.setAttribute("viewBox", "0 0 24 24")
icon.setAttribute("fill", "none")
icon.setAttribute("stroke", "currentColor")
icon.setAttribute("stroke-width", "2")
icon.setAttribute("stroke-linecap", "round")
icon.setAttribute("stroke-linejoin", "round")
icon.setAttribute("aria-hidden", "true")
icon.style.width = "26px"
icon.style.height = "26px"
icon.style.flex = "0 0 auto"
iconPaths.forEach((pathData) => {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path")
path.setAttribute("d", pathData)
icon.appendChild(path)
})
// Visually hidden: the icon carries the meaning, the label serves assistive tech.
text.textContent = getLauncherText(config)
text.style.position = "absolute"
text.style.width = "1px"
text.style.height = "1px"
text.style.overflow = "hidden"
text.style.clip = "rect(0 0 0 0)"
text.style.whiteSpace = "nowrap"
button.style.position = "fixed"
button.style.bottom = "max(20px, env(safe-area-inset-bottom))"
button.style.right = config.position === "left" ? "" : "max(20px, env(safe-area-inset-right))"
button.style.left = config.position === "left" ? "max(20px, env(safe-area-inset-left))" : ""
button.style.zIndex = "2147483000"
button.style.display = "inline-flex"
button.style.alignItems = "center"
button.style.justifyContent = "center"
button.style.width = "56px"
button.style.height = "56px"
button.style.border = "0"
button.style.borderRadius = "999px"
button.style.padding = "0"
button.style.background = themeColor
button.style.color = "#fff"
button.style.boxShadow = restShadow
button.style.cursor = "pointer"
button.style.outline = "none"
button.style.transition = reduceMotion
? "none"
: "transform 180ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 180ms ease"
button.appendChild(icon)
button.appendChild(text)
const lift = () => {
button.style.transform = "translateY(-2px)"
button.style.boxShadow = hoverShadow
}
const rest = () => {
button.style.transform = "none"
button.style.boxShadow = restShadow
}
button.addEventListener("mouseenter", lift)
button.addEventListener("mouseleave", rest)
button.addEventListener("focus", () => {
button.style.boxShadow = `${hoverShadow}, 0 0 0 3px ${themeColor}55`
})
button.addEventListener("blur", rest)
button.addEventListener("click", () => {
if (state.isOpen || state.opening) {
closeWidget()
return
}
// openWidget logs active failures; native clicks have no Promise caller.
void openWidget().catch(() => {})
})
document.body.appendChild(button)
state.button = button
syncLauncherState()
return button
}
function mount(config?: AgentDeskConfig) {
const rawConfig = { ...(config || window.AgentDeskConfig || { channelId: "" }) }
// A remount replaces the previous UI and invalidates every pending request.
// Conversation history belongs to the chat, not this disposable host UI.
destroy()
state.config = resolveConfig(rawConfig)
if (!state.config.channelId) {
console.error("[agent-desk-widget] channelId is required")
return
}
state.mounted = true
state.messageListener = handleWindowMessage
state.resizeListener = applyFrameLayout
window.addEventListener("message", state.messageListener)
window.addEventListener("resize", state.resizeListener)
const mountGeneration = state.mountGeneration
state.configLoading = true
const publicConfig = rawConfig.language
? Promise.resolve(state.config)
: fetchPublicConfig(state.config)
state.configPromise = publicConfig
.then((nextConfig) => {
if (!state.mounted || state.mountGeneration !== mountGeneration) return null
return fetchWidgetConfig(nextConfig)
})
.then((nextConfig) => {
if (!nextConfig || !state.mounted || state.mountGeneration !== mountGeneration) return
state.configLoading = false
state.config = normalizeConfig(nextConfig)
createLauncher()
})
}
function destroy() {
state.mounted = false
state.mountGeneration = (state.mountGeneration || 0) + 1
cancelPendingOpen()
if (state.messageListener) window.removeEventListener("message", state.messageListener)
if (state.resizeListener) window.removeEventListener("resize", state.resizeListener)
state.messageListener = null
state.resizeListener = null
removeFrame()
if (state.button?.parentNode) {
state.button.parentNode.removeChild(state.button)
}
state.button = null
state.isOpen = false
state.isMaximized = false
state.configLoading = false
state.configPromise = null
state.frameConfig = null
state.frameUrl = null
}
function cancelPendingOpen() {
state.openGeneration = (state.openGeneration || 0) + 1
state.cancelOpen?.()
state.cancelOpen = null
state.opening = false
}
function closeWidget() {
cancelPendingOpen()
state.isOpen = false
syncFrameVisibility()
}
async function openWidget() {
if (!state.mounted) mount(state.config || window.AgentDeskConfig)
if (!state.config?.channelId || !state.mounted) return
cancelPendingOpen()
const mountGeneration = state.mountGeneration
const openGeneration = state.openGeneration
const canceled = new Promise<null>((resolve) => {
state.cancelOpen = () => resolve(null)
})
const isCurrentOpen = () =>
state.mounted &&
state.mountGeneration === mountGeneration &&
state.openGeneration === openGeneration
state.opening = true
syncLauncherState()
try {
await Promise.race([state.configPromise, canceled])
if (!isCurrentOpen() || !state.config) return
const prepared = await Promise.race([prepareFrame({ ...state.config }), canceled])
// close(), destroy(), and a newer open()/mount() all cancel this open.
// Do not assign identity, create a frame or flash stale UI afterwards.
if (!prepared || !isCurrentOpen()) return
state.frameUrl = prepared.frameUrl
state.frameConfig = prepared.frameConfig
if (state.frame && state.frame.src !== prepared.frameUrl.toString()) {
// A fresh identity needs a new document. Replacing the element also
// fences delayed load/ready events from its previous document.
removeFrame()
}
if (!state.frame) createFrame()
if (!state.frame) return
state.opening = false
state.isOpen = true
syncFrameVisibility()
} catch (error) {
if (isCurrentOpen()) {
console.error("[agent-desk-widget] open failed", error)
throw error
}
} finally {
if (isCurrentOpen()) {
state.cancelOpen = null
state.opening = false
syncLauncherState()
}
}
}
window.AgentDeskWidget = {
mount,
destroy,
open: () => openWidget(),
close: closeWidget,
getChatUrl: () => {
const config = state.config
? { ...state.config }
: resolveConfig(window.AgentDeskConfig || { channelId: "" })
if (!config.channelId) {
return Promise.reject(new Error("channelId is required"))
}
// A standalone link must not change the active frame's URL or identity.
return prepareFrame(config).then(({ frameUrl }) => frameUrl.toString())
},
} satisfies AgentDeskWidget
if (window.AgentDeskConfig) {
mount(window.AgentDeskConfig)
}
})()

View File

@@ -0,0 +1,60 @@
export type AgentDeskConfig = {
channelId: string
baseUrl?: string
apiBaseUrl?: string
widgetBaseUrl?: string
/** Stable external visitor ID. Uses the browser-local visitor ID when omitted. */
externalId?: string
/**
* Signs `externalId` with the deployment-wide shared identity secret so the
* visitor counts as identified instead of anonymous:
*
* signature = hex(HMAC-SHA256(identitySecret, channelId + "\n" + externalId + "\n" + issuedAt))
*
* `issuedAt` is unix seconds and must be returned alongside the signature so
* the server can expire it. The server only accepts the pair while it is
* within identity.maxAgeMinutes, which bounds how long a captured signature
* stays replayable.
*
* The host owns the secret and should compute this in its own trusted code.
* Without a valid pair the visitor is only ever an anonymous guest, so an
* unsigned embed keeps working unchanged.
*/
signExternalId?: (externalId: string) => { signature: string; issuedAt: number } | Promise<{ signature: string; issuedAt: number }>
/** Visitor display name, only used when first exchanging for a chat token. */
externalName?: string
/** Gets the user JWT issued by the host system before opening support. */
getUserToken?: () => string | Promise<string>
/** Reject missing tokens/session downgrades instead of opening as a guest. */
requireUserToken?: boolean
title?: string
subtitle?: string
language?: string
position?: "left" | "right"
themeColor?: string
width?: string
}
export type SupportChatRuntimeConfig = Omit<AgentDeskConfig, "getUserToken" | "signExternalId"> & {
/** Used only by /support/chat to exchange for a chat token; not part of AgentDeskConfig. */
userToken?: string
externalIdSignature?: string
externalIdSignedAt?: number
}
export type AgentDeskWidget = {
mount: (config?: AgentDeskConfig) => void
destroy: () => void
open: () => Promise<void>
close: () => void
getChatUrl: () => Promise<string>
}
declare global {
interface Window {
AgentDeskConfig?: AgentDeskConfig
AgentDeskWidget?: AgentDeskWidget
__CS_AI_AGENT_WIDGET_CONFIG__?: SupportChatRuntimeConfig
__CS_AI_AGENT_WIDGET_STATE__?: unknown
}
}

View File

@@ -0,0 +1,61 @@
import type { SupportChatRuntimeConfig } from "@/lib/sdk/config-types"
export function readSupportChatRuntimeConfig(): SupportChatRuntimeConfig {
if (typeof window === "undefined") {
return {
channelId: "",
baseUrl: "",
apiBaseUrl: "",
}
}
const query = new URLSearchParams(window.location.search)
const fallback: SupportChatRuntimeConfig = {
channelId:
query.get("channelId") ??
process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() ??
"",
baseUrl:
query.get("baseUrl") ??
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() ??
window.location.origin,
apiBaseUrl:
query.get("apiBaseUrl") ??
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() ??
undefined,
externalId: query.get("externalId") ?? undefined,
externalIdSignature: query.get("externalIdSignature") ?? undefined,
externalIdSignedAt: Number(query.get("externalIdSignedAt")) || undefined,
externalName: query.get("externalName") ?? undefined,
userToken: query.get("userToken") ?? undefined,
requireUserToken: query.get("requireUserToken") === "true",
title: query.get("title") ?? undefined,
subtitle: query.get("subtitle") ?? undefined,
position: (query.get("position") as "left" | "right" | null) ?? undefined,
themeColor: query.get("themeColor") ?? undefined,
width: query.get("width") ?? undefined,
}
if (window.__CS_AI_AGENT_WIDGET_CONFIG__) {
return window.__CS_AI_AGENT_WIDGET_CONFIG__
}
if (window.AgentDeskConfig) {
// The signer functions cannot cross into the iframe; their resolved values
// arrive in the query string and in the init payload instead.
const hostConfig = { ...window.AgentDeskConfig } as Record<string, unknown>
delete hostConfig.getUserToken
delete hostConfig.signExternalId
return {
...fallback,
...hostConfig,
} as SupportChatRuntimeConfig
}
return fallback
}
export function setSupportChatRuntimeConfig(config: SupportChatRuntimeConfig) {
if (typeof window === "undefined") {
return
}
window.__CS_AI_AGENT_WIDGET_CONFIG__ = config
}

View File

@@ -0,0 +1,42 @@
import { summarizeIMMessage } from "@/lib/im-message"
type NotificationMessage = {
messageType: string
content: string
payload?: string
}
export function getNotificationBody(message: NotificationMessage): string {
return summarizeIMMessage(message)
}
export function showNotification(title: string, body: string, onClick?: () => void) {
if (typeof Notification === "undefined") {
return
}
if (Notification.permission === "granted") {
const notification = new Notification(title, {
body,
icon: "/favicon.ico",
badge: "/favicon.ico",
})
if (onClick) {
notification.onclick = () => {
onClick()
notification.close()
}
}
setTimeout(() => {
notification.close()
}, 5000)
} else if (Notification.permission === "default") {
Notification.requestPermission().then((permission) => {
if (permission === "granted") {
showNotification(title, body, onClick)
}
})
}
}

View File

@@ -0,0 +1,193 @@
import test from "node:test"
import assert from "node:assert/strict"
import {
playIncomingMessageSound,
resetAudioContextForTesting,
} from "./sound-alert.ts"
class MockAudioParam {
constructor(defaultValue = 0) {
this.value = defaultValue
this.calls = []
}
setValueAtTime(val, time) {
this.calls.push({ method: "setValueAtTime", val, time })
}
exponentialRampToValueAtTime(val, time) {
this.calls.push({ method: "exponentialRampToValueAtTime", val, time })
}
}
class MockAudioNode {
constructor() {
this.connectedTo = null
}
connect(target) {
this.connectedTo = target
}
}
class MockOscillatorNode extends MockAudioNode {
constructor() {
super()
this.type = "sine"
this.frequency = new MockAudioParam(440)
this.startCalls = []
this.stopCalls = []
}
start(time) {
this.startCalls.push(time)
}
stop(time) {
this.stopCalls.push(time)
}
}
class MockGainNode extends MockAudioNode {
constructor() {
super()
this.gain = new MockAudioParam(1)
}
}
class MockAudioContext {
constructor() {
this.state = "running"
this.currentTime = 5.0
this.destination = new MockAudioNode()
this.oscillators = []
this.gains = []
this.resumeCalled = false
this.resumePromise = Promise.resolve()
}
createOscillator() {
const osc = new MockOscillatorNode()
this.oscillators.push(osc)
return osc
}
createGain() {
const gain = new MockGainNode()
this.gains.push(gain)
return gain
}
resume() {
this.resumeCalled = true
return this.resumePromise
}
}
test("returns false safely in Node.js / SSR environment without window", () => {
const originalWindow = globalThis.window
try {
delete globalThis.window
resetAudioContextForTesting()
const result = playIncomingMessageSound()
assert.equal(result, false)
} finally {
if (originalWindow !== undefined) {
globalThis.window = originalWindow
}
}
})
test("returns false safely when window exists but AudioContext is unsupported", () => {
const originalWindow = globalThis.window
try {
globalThis.window = {}
resetAudioContextForTesting()
const result = playIncomingMessageSound()
assert.equal(result, false)
} finally {
if (originalWindow !== undefined) {
globalThis.window = originalWindow
} else {
delete globalThis.window
}
}
})
test("synthesizes dual-tone chime (587.33Hz and 880.00Hz) with gain ramps", () => {
const originalWindow = globalThis.window
try {
let mockCtx = null
globalThis.window = {
AudioContext: class {
constructor() {
mockCtx = new MockAudioContext()
return mockCtx
}
},
}
resetAudioContextForTesting()
const result = playIncomingMessageSound()
assert.equal(result, true)
assert.ok(mockCtx, "AudioContext should be created")
// Verify 2 oscillators created
assert.equal(mockCtx.oscillators.length, 2)
const [osc1, osc2] = mockCtx.oscillators
// Note 1: 587.33 Hz (D5) for 0.08s
assert.equal(osc1.type, "sine")
assert.equal(osc1.frequency.calls[0].val, 587.33)
assert.equal(osc1.frequency.calls[0].time, 5.0)
assert.equal(osc1.startCalls[0], 5.0)
assert.equal(osc1.stopCalls[0], 5.08)
// Note 2: 880.00 Hz (A5) for 0.16s
assert.equal(osc2.type, "sine")
assert.equal(osc2.frequency.calls[0].val, 880.00)
assert.equal(osc2.frequency.calls[0].time, 5.08)
assert.equal(osc2.startCalls[0], 5.08)
assert.equal(osc2.stopCalls[0], 5.08 + 0.16)
// Verify 2 gains with exponentialRampToValueAtTime
assert.equal(mockCtx.gains.length, 2)
const [gain1, gain2] = mockCtx.gains
const ramp1 = gain1.gain.calls.find(c => c.method === "exponentialRampToValueAtTime")
const ramp2 = gain2.gain.calls.find(c => c.method === "exponentialRampToValueAtTime")
assert.ok(ramp1, "gain1 should have exponential ramp")
assert.ok(ramp2, "gain2 should have exponential ramp")
assert.equal(ramp1.time, 5.08)
assert.equal(ramp2.time, 5.24)
} finally {
if (originalWindow !== undefined) {
globalThis.window = originalWindow
} else {
delete globalThis.window
}
}
})
test("handles suspended AudioContext and catches rejected resume() safely", async () => {
const originalWindow = globalThis.window
try {
let mockCtx = null
globalThis.window = {
AudioContext: class {
constructor() {
mockCtx = new MockAudioContext()
mockCtx.state = "suspended"
mockCtx.resumePromise = Promise.reject(new Error("Autoplay not allowed"))
return mockCtx
}
},
}
resetAudioContextForTesting()
// Should not throw or crash on autoplay rejection
const result = playIncomingMessageSound()
assert.equal(result, true)
assert.equal(mockCtx.resumeCalled, true)
// Wait a tick for promise rejection to settle
await new Promise((resolve) => setTimeout(resolve, 10))
} finally {
if (originalWindow !== undefined) {
globalThis.window = originalWindow
} else {
delete globalThis.window
}
}
})

91
web/lib/sound-alert.ts Normal file
View File

@@ -0,0 +1,91 @@
let sharedAudioContext: AudioContext | null = null
/**
* Resets the cached AudioContext instance (used for testing).
*/
export function resetAudioContextForTesting(): void {
sharedAudioContext = null
}
function getOrCreateAudioContext(): AudioContext | null {
if (typeof window === "undefined") {
return null
}
const AudioContextClass =
window.AudioContext ||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
if (!AudioContextClass) {
return null
}
if (!sharedAudioContext || sharedAudioContext.state === "closed") {
try {
sharedAudioContext = new AudioContextClass()
} catch {
return null
}
}
return sharedAudioContext
}
function scheduleTone(
ctx: AudioContext,
freq: number,
startTime: number,
duration: number,
peakGain = 0.15
): void {
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.type = "sine"
osc.frequency.setValueAtTime(freq, startTime)
gain.gain.setValueAtTime(peakGain, startTime)
gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration)
osc.connect(gain)
gain.connect(ctx.destination)
osc.start(startTime)
osc.stop(startTime + duration)
}
/**
* Plays a gentle dual-tone chime (587.33 Hz D5 -> 880.00 Hz A5) using the Web Audio API.
* Safely handles SSR/Node environments, unsupported browsers, suspended contexts, and
* browser autoplay policy rejections without throwing errors.
*
* @returns boolean indicating whether the sound playback was successfully initiated.
*/
export function playIncomingMessageSound(): boolean {
try {
const ctx = getOrCreateAudioContext()
if (!ctx) {
return false
}
if (ctx.state === "suspended") {
ctx.resume().catch(() => {
// Traps browser autoplay rejection without crashing
})
}
const now = ctx.currentTime
const note1Duration = 0.08
const note2Duration = 0.16
// Note 1: 587.33 Hz (D5) for 0.08s
scheduleTone(ctx, 587.33, now, note1Duration)
// Note 2: 880.00 Hz (A5) for 0.16s
scheduleTone(ctx, 880.00, now + note1Duration, note2Duration)
return true
} catch {
return false
}
}

View File

@@ -0,0 +1,672 @@
"use client"
import { create } from "zustand"
import {
fetchAgentConversations,
fetchAgentMessages,
markAgentMessageRead,
recallAgentMessage,
sendAgentMessage,
uploadAgentConversationAttachment,
uploadAgentConversationImage,
type AgentAsset,
type AgentConversation,
type AgentMessage,
} from "@/lib/api/agent"
import type { RealtimeConnectionStatusValue } from "@/components/realtime-connection-status"
import {
cursorFromLoadedImMessages,
hasMoreAfterLatestImMessageMerge,
mergeImMessagesByIdAsc,
parseImMessageCursorId,
} from "@/lib/im-message-merge"
import {
markMessagesReadToMessageId,
patchConversationList,
patchConversationListWithMessage,
type RealtimeConversationPatch,
} from "@/lib/im-realtime-state"
import { summarizeIMMessage } from "@/lib/im-message"
import { generateUUID } from "@/lib/utils"
import { readSession } from "@/lib/auth"
import {
resolveAgentActivityState,
type AgentActivityInfo,
type AgentActivityEventPayload,
} from "@/lib/agent-activity-state"
import {
purgeExpiredOperatorPresences as purgePresences,
updateOperatorPresenceList,
type OperatorPresencePayload,
type OperatorPresenceRecord,
} from "@/lib/operator-presence-state"
export const agentConversationFilterOptions = [
{ value: "active", labelKey: "conversation.filterActive" },
{ value: "pending", labelKey: "conversation.filterPending" },
{ value: "ai_serving", labelKey: "conversation.filterAiServing" },
{ value: "closed", labelKey: "conversation.filterClosed" },
] as const
export type AgentConversationFilterKey =
(typeof agentConversationFilterOptions)[number]["value"]
function buildConversationQuery(filter: AgentConversationFilterKey, keyword: string) {
const query: Record<string, string | number | undefined> = {
filter,
keyword: keyword.trim() || undefined,
limit: 100,
}
return query
}
type LoadMessagesOptions = {
forceLoading?: boolean
reset?: boolean
}
function ensureArray<T>(value: T[] | null | undefined): T[] {
return Array.isArray(value) ? value : []
}
type AgentConversationsStore = {
searchKeyword: string
conversationFilter: AgentConversationFilterKey
conversations: AgentConversation[]
conversationsLoading: boolean
conversationsLoaded: boolean
selectedConversationId: number | null
activeAgentActivity: AgentActivityInfo | null
messages: AgentMessage[]
messagesLoading: boolean
messagesLoadingMore: boolean
messagesCursor: string
messagesHasMore: boolean
messagesLoadedConversationId: number | null
sending: boolean
uploadingAsset: boolean
recallingMessageId: number
readingMessageId: number
realtimeStatus: RealtimeConnectionStatusValue
operatorPresences: OperatorPresenceRecord[]
setSearchKeyword: (keyword: string) => void
setConversationFilter: (filter: AgentConversationFilterKey) => void
setRealtimeStatus: (status: RealtimeConnectionStatusValue) => void
setConversationTags: (
conversationId: number,
tags: AgentConversation["tags"]
) => void
loadConversations: () => Promise<void>
selectConversation: (conversationId: number) => Promise<void>
loadMessages: (conversationId: number, options?: LoadMessagesOptions) => Promise<void>
loadOlderMessages: () => Promise<void>
syncLatestMessages: (conversationId: number) => Promise<void>
markSelectedConversationRead: () => Promise<void>
sendMessage: (html: string) => Promise<AgentMessage | null>
uploadImage: (file: File) => Promise<AgentAsset | null>
sendAttachment: (file: File) => Promise<AgentMessage | null>
recallMessage: (messageId: number) => Promise<AgentMessage | null>
applyRealtimeMessageCreated: (message: AgentMessage) => void
applyRealtimeAgentActivity: (payload: AgentActivityEventPayload) => void
applyRealtimeOperatorPresence: (payload: OperatorPresencePayload, currentUserId?: number) => void
purgeExpiredOperatorPresences: () => void
applyRealtimeConversationChanged: (patch: RealtimeConversationPatch) => void
applyRealtimeMessageRecalled: (messageId: number, patch: Partial<AgentMessage>) => void
resyncRealtimeData: (conversationId?: number) => Promise<void>
}
let conversationsRequestSeq = 0
let messagesRequestSeq = 0
export const useAgentConversationsStore = create<AgentConversationsStore>((set, get) => ({
searchKeyword: "",
conversationFilter: "active",
conversations: [],
conversationsLoading: false,
conversationsLoaded: false,
selectedConversationId: null,
activeAgentActivity: null,
messages: [],
messagesLoading: false,
messagesLoadingMore: false,
messagesCursor: "",
messagesHasMore: false,
messagesLoadedConversationId: null,
sending: false,
uploadingAsset: false,
recallingMessageId: 0,
readingMessageId: 0,
realtimeStatus: "connecting",
operatorPresences: [],
setSearchKeyword: (keyword) => {
set({ searchKeyword: keyword })
},
setConversationFilter: (filter) => {
set({ conversationFilter: filter })
},
setRealtimeStatus: (status) => {
set({ realtimeStatus: status })
},
setConversationTags: (conversationId, tags) => {
set((state) => ({
conversations: state.conversations.map((item) =>
item.id === conversationId
? {
...item,
tags: tags && tags.length > 0 ? tags : [],
}
: item
),
}))
},
loadConversations: async () => {
const requestSeq = ++conversationsRequestSeq
const store = get()
if (!store.conversationsLoaded) {
set({ conversationsLoading: true })
}
try {
const data = await fetchAgentConversations(
buildConversationQuery(store.conversationFilter, store.searchKeyword)
)
const conversations = ensureArray(data.results)
if (requestSeq !== conversationsRequestSeq) {
return
}
const currentSelectedId = get().selectedConversationId
const hasCurrentSelection =
currentSelectedId !== null && conversations.some((item) => item.id === currentSelectedId)
const nextSelectedId = hasCurrentSelection ? currentSelectedId : (conversations[0]?.id ?? null)
const selectionChanged = nextSelectedId !== currentSelectedId
set({
conversations,
conversationsLoaded: true,
conversationsLoading: false,
selectedConversationId: nextSelectedId,
})
if (nextSelectedId === null) {
set({
messages: [],
messagesLoading: false,
messagesLoadingMore: false,
messagesCursor: "",
messagesHasMore: false,
messagesLoadedConversationId: null,
})
return
}
if (selectionChanged || get().messagesLoadedConversationId === null) {
await get().loadMessages(nextSelectedId, {
forceLoading: true,
reset: true,
})
}
} catch (error) {
if (requestSeq === conversationsRequestSeq) {
set({ conversationsLoading: false })
}
throw error
}
},
selectConversation: async (conversationId) => {
if (get().selectedConversationId === conversationId) {
return
}
set({
selectedConversationId: conversationId,
messages: [],
messagesLoading: true,
messagesLoadingMore: false,
messagesCursor: "",
messagesHasMore: false,
messagesLoadedConversationId: null,
activeAgentActivity: null,
})
await get().loadMessages(conversationId, {
forceLoading: true,
reset: true,
})
},
loadMessages: async (conversationId, options = {}) => {
const requestSeq = ++messagesRequestSeq
const store = get()
const shouldShowLoading =
options.forceLoading || store.messagesLoadedConversationId !== conversationId
if (shouldShowLoading) {
set({
messagesLoading: true,
...(options.reset
? {
messages: [],
messagesCursor: "",
messagesHasMore: false,
}
: {}),
})
}
try {
const data = await fetchAgentMessages({
conversationId,
limit: 50,
})
if (requestSeq !== messagesRequestSeq) {
return
}
if (get().selectedConversationId !== conversationId) {
return
}
const list = ensureArray(data.results)
set({
messages: list,
messagesLoading: false,
messagesLoadedConversationId: conversationId,
messagesCursor:
cursorFromLoadedImMessages(list) || (data.cursor ?? ""),
messagesHasMore: Boolean(data.hasMore),
})
} catch (error) {
if (requestSeq === messagesRequestSeq) {
set({ messagesLoading: false })
}
throw error
}
},
loadOlderMessages: async () => {
const conversationId = get().selectedConversationId
if (!conversationId || get().messagesLoadingMore || !get().messagesHasMore) {
return
}
const cursorId = parseImMessageCursorId(get().messagesCursor)
if (cursorId <= 0) {
return
}
set({ messagesLoadingMore: true })
try {
const data = await fetchAgentMessages({
conversationId,
cursor: cursorId,
limit: 50,
})
if (get().selectedConversationId !== conversationId) {
return
}
const incoming = ensureArray(data.results)
set((state) => {
const merged = mergeImMessagesByIdAsc(state.messages, incoming)
return {
messages: merged,
messagesCursor:
cursorFromLoadedImMessages(merged) ||
(data.cursor ?? state.messagesCursor),
messagesHasMore: Boolean(data.hasMore),
messagesLoadingMore: false,
}
})
} catch (error) {
set({ messagesLoadingMore: false })
throw error
}
},
syncLatestMessages: async (conversationId) => {
if (conversationId <= 0) {
return
}
try {
const data = await fetchAgentMessages({
conversationId,
limit: 50,
})
if (get().selectedConversationId !== conversationId) {
return
}
const batch = ensureArray(data.results)
if (batch.length === 0) {
return
}
set((state) => {
const merged = mergeImMessagesByIdAsc(state.messages, batch)
return {
messages: merged,
messagesCursor:
cursorFromLoadedImMessages(merged) ||
(data.cursor ?? state.messagesCursor),
messagesHasMore: hasMoreAfterLatestImMessageMerge({
previousMessages: state.messages,
previousHasMore: state.messagesHasMore,
merged,
apiHasMore: Boolean(data.hasMore),
}),
}
})
} catch {
// Keep realtime callback errors contained in the store.
}
},
markSelectedConversationRead: async () => {
const store = get()
const conversationId = store.selectedConversationId
const conversation = store.conversations.find((item) => item.id === conversationId)
const lastMessage = store.messages.at(-1)
if (!conversationId || !conversation || !lastMessage) {
return
}
if (
conversation.agentUnreadCount <= 0 &&
(conversation.agentLastReadMessageId ?? 0) >= lastMessage.id
) {
return
}
if (store.readingMessageId === lastMessage.id) {
return
}
set({ readingMessageId: lastMessage.id })
try {
await markAgentMessageRead(conversationId, lastMessage.id)
set((current) => {
if (current.selectedConversationId !== conversationId) {
return { readingMessageId: 0 }
}
return {
readingMessageId: 0,
messages: current.messages.map((item) => {
if (item.id > lastMessage.id) {
return item
}
return item.agentRead ? item : { ...item, agentRead: true }
}),
conversations: current.conversations.map((item) =>
item.id === conversationId
? {
...item,
agentUnreadCount: 0,
agentLastReadMessageId: lastMessage.id,
}
: item
),
}
})
} catch (error) {
set({ readingMessageId: 0 })
throw error
}
},
applyRealtimeMessageCreated: (message) => {
set((state) => {
const isSelected = state.selectedConversationId === message.conversationId
const nextMessages = isSelected
? mergeImMessagesByIdAsc(state.messages, [message])
: state.messages
return {
messages: nextMessages,
activeAgentActivity: isSelected ? null : state.activeAgentActivity,
conversations: patchConversationListWithMessage(
state.conversations,
message
),
}
})
},
applyRealtimeAgentActivity: (payload) => {
set((state) => ({
activeAgentActivity: resolveAgentActivityState(
state.activeAgentActivity,
state.selectedConversationId ?? undefined,
payload
),
}))
},
applyRealtimeOperatorPresence: (payload, currentUserId) => {
const userId = currentUserId || readSession()?.user?.id || 0
set((state) => ({
operatorPresences: updateOperatorPresenceList(
state.operatorPresences,
payload,
userId
),
}))
},
purgeExpiredOperatorPresences: () => {
set((state) => {
const purged = purgePresences(state.operatorPresences)
if (purged.length === state.operatorPresences.length) {
return state
}
return { operatorPresences: purged }
})
},
applyRealtimeConversationChanged: (patch) => {
set((state) => {
const conversationId = patch.id ?? patch.conversationId ?? 0
const isSelected =
conversationId > 0 && state.selectedConversationId === conversationId
let nextMessages = state.messages
if (isSelected) {
if ((patch.agentLastReadMessageId ?? 0) > 0) {
nextMessages = markMessagesReadToMessageId(
nextMessages,
patch.agentLastReadMessageId ?? 0,
"agent",
patch.agentLastReadAt
)
}
if ((patch.customerLastReadMessageId ?? 0) > 0) {
nextMessages = markMessagesReadToMessageId(
nextMessages,
patch.customerLastReadMessageId ?? 0,
"customer",
patch.customerLastReadAt
)
}
}
return {
messages: nextMessages,
conversations: patchConversationList(state.conversations, patch),
...(isSelected && (patch.status === 4 || patch.closedAt)
? { activeAgentActivity: null }
: {}),
}
})
},
applyRealtimeMessageRecalled: (messageId, patch) => {
if (messageId <= 0) {
return
}
set((state) => ({
messages: state.messages.map((item) =>
item.id === messageId ? { ...item, ...patch, id: item.id } : item
),
}))
},
resyncRealtimeData: async (conversationId) => {
await get().loadConversations()
const selectedConversationId = get().selectedConversationId
const targetConversationId = conversationId ?? selectedConversationId
if (targetConversationId && selectedConversationId === targetConversationId) {
await get().loadMessages(targetConversationId, {
forceLoading: false,
reset: false,
})
}
},
sendMessage: async (html) => {
const trimmedContent = html.trim()
const { selectedConversationId, sending } = get()
if (!selectedConversationId || !trimmedContent || sending) {
return null
}
set({ sending: true })
try {
const message = await sendAgentMessage({
conversationId: selectedConversationId,
messageType: "html",
content: trimmedContent,
clientMsgId: `agent_${generateUUID()}`,
})
if (get().selectedConversationId === selectedConversationId) {
set((current) => ({
messages: current.messages.some((m) => m.id === message.id)
? current.messages.map((m) => (m.id === message.id ? message : m))
: [...current.messages, message],
conversations: patchConversationList(
patchConversationListWithMessage(current.conversations, message),
{
conversationId: selectedConversationId,
agentUnreadCount: 0,
customerUnreadCount:
(current.conversations.find((item) => item.id === selectedConversationId)
?.customerUnreadCount ?? 0) + 1,
agentLastReadMessageId: message.id,
}
),
}))
}
return message
} finally {
set({ sending: false })
}
},
uploadImage: async (file) => {
const { selectedConversationId, sending, uploadingAsset } = get()
if (!selectedConversationId || sending || uploadingAsset) {
return null
}
set({ uploadingAsset: true })
try {
return await uploadAgentConversationImage(selectedConversationId, file)
} finally {
set({ uploadingAsset: false })
}
},
sendAttachment: async (file) => {
const { selectedConversationId, sending, uploadingAsset } = get()
if (!selectedConversationId || sending || uploadingAsset) {
return null
}
set({ uploadingAsset: true })
try {
const asset = await uploadAgentConversationAttachment(selectedConversationId, file)
const message = await sendAgentMessage({
conversationId: selectedConversationId,
messageType: "attachment",
content: asset.filename,
payload: JSON.stringify({ assetId: asset.assetId }),
clientMsgId: `agent_attachment_${generateUUID()}`,
})
if (get().selectedConversationId === selectedConversationId) {
set((current) => ({
messages: current.messages.some((m) => m.id === message.id)
? current.messages.map((m) => (m.id === message.id ? message : m))
: [...current.messages, message],
conversations: patchConversationList(
patchConversationListWithMessage(current.conversations, message),
{
conversationId: selectedConversationId,
agentUnreadCount: 0,
customerUnreadCount:
(current.conversations.find((item) => item.id === selectedConversationId)
?.customerUnreadCount ?? 0) + 1,
agentLastReadMessageId: message.id,
}
),
}))
}
return message
} finally {
set({ uploadingAsset: false })
}
},
recallMessage: async (messageId) => {
const { selectedConversationId, recallingMessageId } = get()
if (!selectedConversationId || messageId <= 0 || recallingMessageId === messageId) {
return null
}
set({ recallingMessageId: messageId })
try {
const message = await recallAgentMessage(messageId)
if (get().selectedConversationId === selectedConversationId) {
set((current) => {
const nextMessages = current.messages.map((item) =>
item.id === message.id ? message : item
)
const lastActiveMessage = [...nextMessages]
.reverse()
.find((item) => !item.recalledAt && item.sendStatus !== 6)
return {
recallingMessageId: 0,
messages: nextMessages,
conversations: current.conversations.map((item) =>
item.id === selectedConversationId
? {
...item,
lastMessageId: lastActiveMessage?.id ?? 0,
lastMessageAt: lastActiveMessage?.sentAt ?? "",
lastMessageSummary: lastActiveMessage
? summarizeIMMessage(lastActiveMessage)
: "",
}
: item
),
}
})
} else {
set({ recallingMessageId: 0 })
}
return message
} catch (error) {
set({ recallingMessageId: 0 })
throw error
}
},
}))
export const agentConversationSelectors = {
selectedConversation: (state: AgentConversationsStore) =>
state.conversations.find((item) => item.id === state.selectedConversationId) ?? null,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
export function articleHeadingId(title: string, index: number) {
const normalized = title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-|-$/g, "")
return `section-${index + 1}-${normalized || "heading"}`
}
export function markdownHeadingText(value: string) {
return value
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/<[^>]+>/g, "")
.replace(/[*_~`]/g, "")
.trim()
}

View File

@@ -0,0 +1,127 @@
import assert from "node:assert/strict"
import test from "node:test"
import { readFile } from "node:fs/promises"
import ts from "typescript"
import vm from "node:vm"
async function loadModule() {
const source = await readFile(
new URL("./support-conversation-i18n.ts", import.meta.url),
"utf8"
)
const compiled = ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2017,
module: ts.ModuleKind.CommonJS,
},
fileName: "support-conversation-i18n.ts",
})
const messages = {}
const sandbox = {
exports: {},
module: { exports: {} },
require: (name) => {
if (name === "@/i18n/messages") {
return {
translateCurrentMessage: (key) => messages[key] ?? key,
translateMessage: (_locale, key) => messages[key] ?? key,
}
}
throw new Error(`unexpected require: ${name}`)
},
}
sandbox.exports = sandbox.module.exports
vm.runInNewContext(compiled.outputText, sandbox)
return { module: sandbox.module.exports, messages }
}
const LOCALES = ["zh-CN", "en-US", "ru-RU"]
async function readMessages(locale) {
const raw = await readFile(
new URL(`../messages/${locale}.json`, import.meta.url),
"utf8"
)
return JSON.parse(raw)
}
test("every conversation status maps to a key that exists in all locales", async () => {
const { module, messages } = await loadModule()
const catalogues = Object.fromEntries(
await Promise.all(
LOCALES.map(async (locale) => [locale, await readMessages(locale)])
)
)
for (const locale of LOCALES) {
for (const status of [1, 2, 3, 4]) {
const key = module.getConversationStatusMessageKey(status)
const expected = lookup(catalogues[locale], key)
messages[key] = expected
assert.ok(expected, `missing ${key} in ${locale}`)
assert.equal(
module.getConversationStatusLabel(status),
expected,
`status ${status} is not localized for ${locale}`
)
}
}
})
test("no two statuses share a label in any locale", async () => {
const catalogues = Object.fromEntries(
await Promise.all(LOCALES.map(async (locale) => [locale, await readMessages(locale)]))
)
for (const locale of LOCALES) {
const labels = [1, 2, 3, 4].map((status) =>
lookup(catalogues[locale], statusKey(status))
)
assert.equal(
new Set(labels).size,
labels.length,
`duplicate status labels in ${locale}: ${labels.join(" / ")}`
)
}
})
// The helper deals in fully qualified "supportChat.x" keys while the catalogues
// are already scoped to the supportChat namespace.
function lookup(catalogue, key) {
return key.split(".").reduce((node, part) => node?.[part], catalogue)
}
function statusKey(status) {
return {
1: "supportChat.statusAiServing",
2: "supportChat.statusPending",
3: "supportChat.statusActive",
4: "supportChat.statusClosed",
}[status]
}
test("an unknown status still resolves to a defined label", async () => {
const { module, messages } = await loadModule()
const en = await readMessages("en-US")
messages[module.getConversationStatusMessageKey(99)] =
en.supportChat.statusClosed
assert.equal(
module.getConversationStatusLabel(99),
en.supportChat.statusClosed
)
assert.equal(
module.getConversationStatusMessageKey(0),
module.getConversationStatusMessageKey(4)
)
})
test("the fallback request title is localized everywhere", async () => {
const { module, messages } = await loadModule()
messages["supportChat.requestFallbackTitle"] = "Support request"
assert.equal(
module.getConversationFallbackTitle("ru-RU"),
"Support request"
)
})

View File

@@ -0,0 +1,46 @@
import { translateCurrentMessage, translateMessage } from "@/i18n/messages"
import type { AppLocale } from "@/i18n/config"
/**
* Localized display names for IMConversationStatus.
*
* The generated enum labels are Chinese-only, so they cannot be shown to an
* English or Russian visitor. Keeping the number-to-key mapping here - rather
* than switching on locale inside a component - means the widget, the demo and
* any later surface all read the same vocabulary.
*/
const CONVERSATION_STATUS_MESSAGE_KEYS: Record<number, string> = {
1: "supportChat.statusAiServing",
2: "supportChat.statusPending",
3: "supportChat.statusActive",
4: "supportChat.statusClosed",
}
/**
* Falls back to the closed label for an unknown status. A request is either
* finished or not, and mislabelling it as finished is the safer error: it
* invites the visitor to reopen it rather than hiding that it is still live.
*/
const UNKNOWN_STATUS_MESSAGE_KEY = "supportChat.statusClosed"
export function getConversationStatusMessageKey(status: number): string {
return CONVERSATION_STATUS_MESSAGE_KEYS[status] ?? UNKNOWN_STATUS_MESSAGE_KEY
}
export function getConversationStatusLabel(
status: number,
locale?: AppLocale
): string {
const key = getConversationStatusMessageKey(status)
return locale ? translateMessage(locale, key) : translateCurrentMessage(key)
}
/**
* The generic title for a request the visitor did not name. Subject wins when
* present; this is what a list row shows instead.
*/
export function getConversationFallbackTitle(locale?: AppLocale): string {
return locale
? translateMessage(locale, "supportChat.requestFallbackTitle")
: translateCurrentMessage("supportChat.requestFallbackTitle")
}

View File

@@ -0,0 +1,269 @@
import assert from "node:assert/strict"
import test from "node:test"
import { readFile } from "node:fs/promises"
import {
buildSelectConversationPatch,
findConversationById,
isOpenImConversation,
pickConversationToResume,
resolveConversationTitle,
sortConversationsByRecency,
totalUnreadConversations,
upsertConversation,
CLOSED_IM_CONVERSATION_STATUS,
} from "./support-conversation-list.ts"
const AISERVING = 1
const PENDING = 2
const ACTIVE = 3
const CLOSED = 4
function makeConversation(overrides = {}) {
return {
id: 1,
channelId: 1,
customerName: "Alice",
status: ACTIVE,
serviceMode: 3,
priority: 0,
currentAssigneeId: 0,
lastMessageId: 0,
customerUnreadCount: 0,
agentUnreadCount: 0,
customerLastReadMessageId: 0,
agentLastReadMessageId: 0,
closedBy: 0,
...overrides,
}
}
test("the mirrored closed status still matches the generated backend enum", async () => {
const source = await readFile(
new URL("./generated/enums.ts", import.meta.url),
"utf8"
)
const block = source.match(/export enum IMConversationStatus \{[^}]*\}/)
assert.ok(block, "expected an IMConversationStatus enum in the generated file")
const values = Object.fromEntries(
[...block[0].matchAll(/(\w+)\s*=\s*(\d+)/g)].map((match) => [
match[1],
Number(match[2]),
])
)
assert.equal(values.Closed, CLOSED_IM_CONVERSATION_STATUS)
// The server counts a conversation open while it is any of these three.
assert.deepEqual(
{ serving: values.AIServing, pending: values.Pending, active: values.Active },
{ serving: AISERVING, pending: PENDING, active: ACTIVE }
)
})
test("anything not closed is still an open request", () => {
assert.equal(isOpenImConversation(makeConversation({ status: AISERVING })), true)
assert.equal(isOpenImConversation(makeConversation({ status: PENDING })), true)
assert.equal(isOpenImConversation(makeConversation({ status: ACTIVE })), true)
assert.equal(isOpenImConversation(makeConversation({ status: CLOSED })), false)
// A status the server adds later defaults to open rather than being mistaken
// for a finished request.
assert.equal(isOpenImConversation(makeConversation({ status: 99 })), true)
})
test("a closed request is not open, and neither is nothing", () => {
assert.equal(isOpenImConversation(null), false)
assert.equal(isOpenImConversation(undefined), false)
})
test("sorting puts the most recently active request first", () => {
const list = [
makeConversation({ id: 1, lastActiveAt: "2026-10-01 10:00:00" }),
makeConversation({ id: 2, lastActiveAt: "2026-10-03 10:00:00" }),
makeConversation({ id: 3, lastActiveAt: "2026-10-02 10:00:00" }),
]
assert.deepEqual(
sortConversationsByRecency(list).map((item) => item.id),
[2, 3, 1]
)
})
// The server writes time.DateTime, which has no timezone marker. Parsing it as
// a bare local timestamp keeps it consistent with formatDateTime elsewhere.
test("server timestamps without a zone still order correctly", () => {
const list = [
makeConversation({ id: 1, lastActiveAt: "2026-10-01 09:00:00" }),
makeConversation({ id: 2, lastActiveAt: "2026-10-01 11:00:00" }),
]
assert.deepEqual(
sortConversationsByRecency(list).map((item) => item.id),
[2, 1]
)
})
test("a brand new request with no timestamps sorts ahead by id", () => {
const list = [
makeConversation({ id: 9 }),
makeConversation({ id: 12 }),
makeConversation({ id: 3, lastActiveAt: "2026-10-01 09:00:00" }),
]
assert.deepEqual(
sortConversationsByRecency(list).map((item) => item.id),
[12, 9, 3]
)
})
test("an unparsable timestamp does not become NaN and scramble the order", () => {
const list = [
makeConversation({ id: 1, lastActiveAt: "not-a-date" }),
makeConversation({ id: 2, lastActiveAt: "2026-10-01 09:00:00" }),
]
assert.deepEqual(
sortConversationsByRecency(list).map((item) => item.id),
[2, 1]
)
})
test("lastMessageAt stands in when lastActiveAt is missing", () => {
const list = [
makeConversation({ id: 1, lastMessageAt: "2026-10-01 08:00:00" }),
makeConversation({ id: 2, lastMessageAt: "2026-10-01 10:00:00" }),
]
assert.deepEqual(
sortConversationsByRecency(list).map((item) => item.id),
[2, 1]
)
})
test("upsert merges an existing request instead of duplicating it", () => {
const list = [makeConversation({ id: 1, subject: "old" })]
const next = upsertConversation(list, makeConversation({ id: 1, subject: "new" }))
assert.equal(next.length, 1)
assert.equal(next[0].subject, "new")
})
test("upsert keeps fields the patch does not carry", () => {
const list = [makeConversation({ id: 1, subject: "kept", customerUnreadCount: 4 })]
const next = upsertConversation(list, { id: 1, status: CLOSED })
assert.equal(next[0].subject, "kept")
assert.equal(next[0].customerUnreadCount, 4)
assert.equal(next[0].status, CLOSED)
})
test("upserting a new request puts it at the top", () => {
const list = [makeConversation({ id: 1, lastActiveAt: "2026-10-01 09:00:00" })]
const next = upsertConversation(list, makeConversation({ id: 2 }))
assert.deepEqual(next.map((item) => item.id), [2, 1])
})
test("upsert does not mutate the input", () => {
const list = [makeConversation({ id: 1 })]
upsertConversation(list, makeConversation({ id: 2 }))
assert.equal(list.length, 1)
})
test("findConversationById returns null rather than throwing", () => {
const list = [makeConversation({ id: 7 })]
assert.equal(findConversationById(list, 7).id, 7)
assert.equal(findConversationById(list, 8), null)
assert.equal(findConversationById(list, 0), null)
})
test("resume picks the newest open request, skipping closed ones", () => {
const list = [
makeConversation({ id: 1, status: CLOSED, lastActiveAt: "2026-10-05 10:00:00" }),
makeConversation({ id: 2, status: ACTIVE, lastActiveAt: "2026-10-01 10:00:00" }),
]
assert.equal(pickConversationToResume(list).id, 2)
})
test("resume returns null when everything is closed so a new one gets started", () => {
const list = [
makeConversation({ id: 1, status: CLOSED }),
makeConversation({ id: 2, status: CLOSED }),
]
assert.equal(pickConversationToResume(list), null)
})
test("resume returns null for a visitor who never wrote to us", () => {
assert.equal(pickConversationToResume([]), null)
})
test("title prefers the visitor's subject", () => {
const conversation = makeConversation({
subject: "Проблема с оплатой",
lastMessageSummary: "Здравствуйте",
})
assert.equal(resolveConversationTitle(conversation, "Запрос"), "Проблема с оплатой")
})
test("title falls back to the last message, then to the localized label", () => {
assert.equal(
resolveConversationTitle(makeConversation({ lastMessageSummary: "Здравствуйте" }), "Запрос"),
"Здравствуйте"
)
assert.equal(resolveConversationTitle(makeConversation(), "Запрос"), "Запрос")
assert.equal(resolveConversationTitle(null, "Запрос"), "Запрос")
})
test("a whitespace-only subject is treated as absent", () => {
assert.equal(
resolveConversationTitle(makeConversation({ subject: " ", lastMessageSummary: "Привет" }), "Запрос"),
"Привет"
)
})
test("unread totals across requests include the ones not being viewed", () => {
const list = [
makeConversation({ id: 1, customerUnreadCount: 2 }),
makeConversation({ id: 2, customerUnreadCount: 3 }),
makeConversation({ id: 3, customerUnreadCount: 0 }),
]
assert.equal(totalUnreadConversations(list), 5)
})
test("a missing unread count counts as zero", () => {
assert.equal(
totalUnreadConversations([
makeConversation({ id: 1 }),
{ ...makeConversation({ id: 2 }), customerUnreadCount: undefined },
]),
0
)
})
test("switching requests re-derives the conversation along with the active id", () => {
const a = makeConversation({ id: 1 })
const b = makeConversation({ id: 2 })
const patch = buildSelectConversationPatch(
{ conversations: [a, b], activeConversationId: 1 },
2
)
assert.equal(patch.activeConversationId, 2)
assert.equal(patch.conversation.id, 2)
assert.deepEqual(patch.messages, [])
assert.equal(patch.messagesHasMore, false)
})
test("switching to an unknown or already active request is a no-op", () => {
const a = makeConversation({ id: 1 })
const state = { conversations: [a], activeConversationId: 1 }
assert.equal(buildSelectConversationPatch(state, 99), null)
assert.equal(buildSelectConversationPatch(state, 1), null)
})

View File

@@ -0,0 +1,199 @@
import type { ImConversation } from "./api/im"
/**
* Mirrors IMConversationStatus.Closed from the generated backend enum.
*
* It is spelled out as a literal rather than imported because Node's type
* stripping cannot load a TypeScript `enum`, and these helpers have to stay
* unit-testable outside the bundler. support-conversation-list.test.mjs reads
* the generated file and fails if this ever drifts, so this is a guarded mirror
* of the generated enum, not a second source of truth.
*/
export const CLOSED_IM_CONVERSATION_STATUS = 4
/**
* Parses an API timestamp string into a millisecond timestamp.
* If the string does not specify a timezone offset, it is interpreted as UTC.
*/
export function parseApiTimestamp(
value?: string | number | Date | null
): number {
if (value === null || value === undefined) {
return 0
}
if (value instanceof Date) {
const time = value.getTime()
return Number.isFinite(time) ? time : 0
}
if (typeof value === "number") {
return Number.isFinite(value) ? value : 0
}
if (typeof value !== "string") {
return 0
}
const trimmed = value.trim()
if (!trimmed) {
return 0
}
let normalized = trimmed.replace(" ", "T")
if (!/(Z|[+-]\d{2}(?::?\d{2})?)$/i.test(normalized)) {
normalized += "Z"
}
const timestamp = Date.parse(normalized)
return Number.isFinite(timestamp) ? timestamp : 0
}
/**
* A request counts as open until it is closed, rather than by listing the open
* statuses. The server's own definition is "status is one of ai_serving,
* pending, active", so any new status it introduces is open by default here
* instead of being silently treated as finished.
*/
export function isOpenImConversation(
conversation: ImConversation | null | undefined
): boolean {
if (!conversation) {
return false
}
return conversation.status !== CLOSED_IM_CONVERSATION_STATUS
}
function recencyTimestamp(conversation: ImConversation): number {
const candidates = [conversation.lastActiveAt, conversation.lastMessageAt]
for (const value of candidates) {
if (!value) {
continue
}
const parsed = parseApiTimestamp(value)
if (parsed > 0) {
return parsed
}
}
return 0
}
/**
* Most recently active first, matching the server's list ordering.
*
* A conversation that was just created has no timestamps yet, so treating an
* unknown time as "oldest" would drop the visitor's brand new request to the
* bottom of the switcher. Whenever either side has no usable timestamp we fall
* back to the descending id instead: ids are monotonic, so that still puts the
* newest request first. Array.prototype.sort is stable, so equal keys keep
* their existing relative order instead of shuffling between renders.
*/
export function sortConversationsByRecency(
conversations: readonly ImConversation[]
): ImConversation[] {
return [...conversations].sort((left, right) => {
const leftTime = recencyTimestamp(left)
const rightTime = recencyTimestamp(right)
if (leftTime > 0 && rightTime > 0 && leftTime !== rightTime) {
return rightTime - leftTime
}
return right.id - left.id
})
}
export function upsertConversation(
conversations: readonly ImConversation[],
conversation: ImConversation
): ImConversation[] {
const existing = conversations.some((item) => item.id === conversation.id)
const next = existing
? conversations.map((item) =>
item.id === conversation.id ? { ...item, ...conversation } : item
)
: [conversation, ...conversations]
return sortConversationsByRecency(next)
}
export function findConversationById(
conversations: readonly ImConversation[],
conversationId: number
): ImConversation | null {
if (!conversationId) {
return null
}
return conversations.find((item) => item.id === conversationId) ?? null
}
/**
* The request to rejoin on load: the newest still-open one.
*
* Returns null when every request is closed, which is the signal to start a
* fresh one. Selecting a closed request here would leave the visitor staring at
* a read-only thread with the composer disabled and no way forward.
*/
export function pickConversationToResume(
conversations: readonly ImConversation[]
): ImConversation | null {
return (
sortConversationsByRecency(conversations).find((item) =>
isOpenImConversation(item)
) ?? null
)
}
/**
* What to show as a request's title. The visitor's own subject wins; otherwise
* the latest message stands in, and only then do we fall back to the caller's
* localized generic label.
*/
export function resolveConversationTitle(
conversation: ImConversation | null | undefined,
fallbackLabel: string
): string {
const subject = conversation?.subject?.trim()
if (subject) {
return subject
}
const summary = conversation?.lastMessageSummary?.trim()
if (summary) {
return summary
}
return fallbackLabel
}
/**
* Total unread across every request, for the switcher's badge. A visitor needs
* to notice activity in a request they are not currently looking at.
*/
export function totalUnreadConversations(
conversations: readonly ImConversation[]
): number {
return conversations.reduce(
(total, item) => total + (Number(item.customerUnreadCount) || 0),
0
)
}
/**
* Builds the state patch for switching the visible request. `conversation` is
* re-derived from the list together with the active id, so the header, the
* composer and the message loader can never point at different requests.
* Returns null when there is nothing to switch to.
*/
export function buildSelectConversationPatch(
state: {
conversations: readonly ImConversation[]
activeConversationId: number
},
conversationId: number
) {
const target = findConversationById(state.conversations, conversationId)
if (!target || target.id === state.activeConversationId) {
return null
}
return {
conversations: state.conversations,
activeConversationId: target.id,
conversation: target,
// Messages belong to the request they were loaded for.
messages: [] as never[],
messagesCursor: "",
messagesHasMore: false,
messagesLoadingMore: false,
readingMessageId: 0,
error: "",
}
}

Some files were not shown because too many files have changed in this diff Show More