chore: complete clean Agent Desk source snapshot
This commit is contained in:
280
web/components/ui/menubar.tsx
Normal file
280
web/components/ui/menubar.tsx
Normal 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,
|
||||||
|
}
|
||||||
131
web/components/ui/message-scroller.tsx
Normal file
131
web/components/ui/message-scroller.tsx
Normal 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,
|
||||||
|
}
|
||||||
92
web/components/ui/message.tsx
Normal file
92
web/components/ui/message.tsx
Normal 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,
|
||||||
|
}
|
||||||
90
web/components/ui/popover.tsx
Normal file
90
web/components/ui/popover.tsx
Normal 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,
|
||||||
|
}
|
||||||
50
web/components/ui/resizable.tsx
Normal file
50
web/components/ui/resizable.tsx
Normal 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 }
|
||||||
55
web/components/ui/scroll-area.tsx
Normal file
55
web/components/ui/scroll-area.tsx
Normal 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 }
|
||||||
201
web/components/ui/select.tsx
Normal file
201
web/components/ui/select.tsx
Normal 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,
|
||||||
|
}
|
||||||
25
web/components/ui/separator.tsx
Normal file
25
web/components/ui/separator.tsx
Normal 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
135
web/components/ui/sheet.tsx
Normal 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,
|
||||||
|
}
|
||||||
744
web/components/ui/sidebar.tsx
Normal file
744
web/components/ui/sidebar.tsx
Normal 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,
|
||||||
|
}
|
||||||
13
web/components/ui/skeleton.tsx
Normal file
13
web/components/ui/skeleton.tsx
Normal 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 }
|
||||||
49
web/components/ui/sonner.tsx
Normal file
49
web/components/ui/sonner.tsx
Normal 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 }
|
||||||
32
web/components/ui/switch.tsx
Normal file
32
web/components/ui/switch.tsx
Normal 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
116
web/components/ui/table.tsx
Normal 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,
|
||||||
|
}
|
||||||
82
web/components/ui/tabs.tsx
Normal file
82
web/components/ui/tabs.tsx
Normal 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 }
|
||||||
18
web/components/ui/textarea.tsx
Normal file
18
web/components/ui/textarea.tsx
Normal 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 }
|
||||||
89
web/components/ui/toggle-group.tsx
Normal file
89
web/components/ui/toggle-group.tsx
Normal 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 }
|
||||||
45
web/components/ui/toggle.tsx
Normal file
45
web/components/ui/toggle.tsx
Normal 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 }
|
||||||
66
web/components/ui/tooltip.tsx
Normal file
66
web/components/ui/tooltip.tsx
Normal 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 }
|
||||||
142
web/components/workbench-header.tsx
Normal file
142
web/components/workbench-header.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
web/components/workbench-rail.tsx
Normal file
86
web/components/workbench-rail.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
web/components/workspace-switcher.test.mjs
Normal file
39
web/components/workspace-switcher.test.mjs
Normal 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
233
web/components/workspace-switcher.tsx
Normal file
233
web/components/workspace-switcher.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
web/e2e/workbench-function.spec.ts
Normal file
127
web/e2e/workbench-function.spec.ts
Normal 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
8
web/embed.go
Normal 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
19
web/embed_dev.go
Normal 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
20
web/embed_test.go
Normal 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
18
web/eslint.config.mjs
Normal 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;
|
||||||
286
web/hooks/use-agent-conversation-realtime.ts
Normal file
286
web/hooks/use-agent-conversation-realtime.ts
Normal 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
25
web/hooks/use-lg-media.ts
Normal 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
19
web/hooks/use-mobile.ts
Normal 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
57
web/i18n/config.test.mjs
Normal 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
51
web/i18n/config.ts
Normal 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
|
||||||
|
}
|
||||||
91
web/i18n/legal-messages.test.mjs
Normal file
91
web/i18n/legal-messages.test.mjs
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
174
web/i18n/message-integrity.test.mjs
Normal file
174
web/i18n/message-integrity.test.mjs
Normal 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
53
web/i18n/messages.ts
Normal 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
87
web/i18n/provider.tsx
Normal 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
|
||||||
|
}
|
||||||
107
web/lib/agent-activity-label.test.mjs
Normal file
107
web/lib/agent-activity-label.test.mjs
Normal 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-ассистент думает...")
|
||||||
|
})
|
||||||
88
web/lib/agent-activity-label.ts
Normal file
88
web/lib/agent-activity-label.ts
Normal 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")
|
||||||
|
}
|
||||||
74
web/lib/agent-activity-state.test.mjs
Normal file
74
web/lib/agent-activity-state.test.mjs
Normal 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",
|
||||||
|
})
|
||||||
|
})
|
||||||
36
web/lib/agent-activity-state.ts
Normal file
36
web/lib/agent-activity-state.ts
Normal 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 } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
27
web/lib/agent-conversation-realtime.test.mjs
Normal file
27
web/lib/agent-conversation-realtime.test.mjs
Normal 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
|
||||||
|
)
|
||||||
|
})
|
||||||
18
web/lib/agent-conversation-realtime.ts
Normal file
18
web/lib/agent-conversation-realtime.ts
Normal 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
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
277
web/lib/api/agent.ts
Normal 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
75
web/lib/api/auth.ts
Normal 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
152
web/lib/api/client.ts
Normal 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
77
web/lib/api/company.ts
Normal 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
21
web/lib/api/config.ts
Normal 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
|
||||||
|
}
|
||||||
77
web/lib/api/customer-contact.ts
Normal file
77
web/lib/api/customer-contact.ts
Normal 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
108
web/lib/api/customer.ts
Normal 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
81
web/lib/api/dashboard.ts
Normal 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
595
web/lib/api/im.ts
Normal 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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
82
web/lib/api/notification.ts
Normal file
82
web/lib/api/notification.ts
Normal 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()}`
|
||||||
|
}
|
||||||
31
web/lib/api/organization.ts
Normal file
31
web/lib/api/organization.ts
Normal 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 }),
|
||||||
|
})
|
||||||
|
}
|
||||||
134
web/lib/api/setup-readiness.ts
Normal file
134
web/lib/api/setup-readiness.ts
Normal 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),
|
||||||
|
})
|
||||||
|
}
|
||||||
176
web/lib/api/support-community.ts
Normal file
176
web/lib/api/support-community.ts
Normal 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}` : ""
|
||||||
|
}
|
||||||
19
web/lib/api/support-config.ts
Normal file
19
web/lib/api/support-config.ts
Normal 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
90
web/lib/api/support.ts
Normal 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
239
web/lib/api/ticket.ts
Normal 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
5
web/lib/api/types.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export type PageData<T> = {
|
||||||
|
cursor?: string
|
||||||
|
hasMore: boolean
|
||||||
|
results: T[]
|
||||||
|
}
|
||||||
16
web/lib/api/websocket.ts
Normal file
16
web/lib/api/websocket.ts
Normal 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
68
web/lib/auth.ts
Normal 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
15
web/lib/enums.ts
Normal 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
16
web/lib/fonts.ts
Normal 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
391
web/lib/generated/enums.ts
Normal 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
232
web/lib/im-editor-image.ts
Normal 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
177
web/lib/im-message-merge.ts
Normal 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
153
web/lib/im-message.ts
Normal 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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'")
|
||||||
|
.replaceAll("\n", "<br>")
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHTMLAttr(value: string) {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
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>`
|
||||||
|
}
|
||||||
172
web/lib/im-realtime-state.ts
Normal file
172
web/lib/im-realtime-state.ts
Normal 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
40
web/lib/im-realtime.ts
Normal 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)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
56
web/lib/knowledge-i18n.test.mjs
Normal file
56
web/lib/knowledge-i18n.test.mjs
Normal 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
48
web/lib/knowledge-i18n.ts
Normal 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
|
||||||
|
}
|
||||||
92
web/lib/navigation-active.test.mjs
Normal file
92
web/lib/navigation-active.test.mjs
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
40
web/lib/navigation-active.ts
Normal file
40
web/lib/navigation-active.ts
Normal 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
352
web/lib/navigation.tsx
Normal 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;
|
||||||
|
}
|
||||||
118
web/lib/notification-i18n.test.mjs
Normal file
118
web/lib/notification-i18n.test.mjs
Normal 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)
|
||||||
|
})
|
||||||
117
web/lib/notification-i18n.ts
Normal file
117
web/lib/notification-i18n.ts
Normal 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")
|
||||||
|
}
|
||||||
187
web/lib/operator-presence-state.test.mjs
Normal file
187
web/lib/operator-presence-state.test.mjs
Normal 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)
|
||||||
|
})
|
||||||
83
web/lib/operator-presence-state.ts
Normal file
83
web/lib/operator-presence-state.ts
Normal 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
|
||||||
71
web/lib/permission-i18n.test.mjs
Normal file
71
web/lib/permission-i18n.test.mjs
Normal 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
223
web/lib/permission-i18n.ts
Normal 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)
|
||||||
|
}
|
||||||
99
web/lib/realtime-connection.test.mjs
Normal file
99
web/lib/realtime-connection.test.mjs
Normal 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()
|
||||||
|
})
|
||||||
216
web/lib/realtime-connection.ts
Normal file
216
web/lib/realtime-connection.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
54
web/lib/role-i18n.test.mjs
Normal file
54
web/lib/role-i18n.test.mjs
Normal 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
27
web/lib/role-i18n.ts
Normal 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
|
||||||
|
}
|
||||||
305
web/lib/sdk/agent-desk-sdk.test.mjs
Normal file
305
web/lib/sdk/agent-desk-sdk.test.mjs
Normal 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")
|
||||||
|
})
|
||||||
772
web/lib/sdk/agent-desk-sdk.ts
Normal file
772
web/lib/sdk/agent-desk-sdk.ts
Normal 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)
|
||||||
|
}
|
||||||
|
})()
|
||||||
60
web/lib/sdk/config-types.ts
Normal file
60
web/lib/sdk/config-types.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
61
web/lib/sdk/runtime-config.ts
Normal file
61
web/lib/sdk/runtime-config.ts
Normal 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
|
||||||
|
}
|
||||||
42
web/lib/services/notification.ts
Normal file
42
web/lib/services/notification.ts
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
193
web/lib/sound-alert.test.mjs
Normal file
193
web/lib/sound-alert.test.mjs
Normal 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
91
web/lib/sound-alert.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
672
web/lib/stores/agent-conversations.ts
Normal file
672
web/lib/stores/agent-conversations.ts
Normal 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,
|
||||||
|
}
|
||||||
1007
web/lib/stores/support-chat.ts
Normal file
1007
web/lib/stores/support-chat.ts
Normal file
File diff suppressed because it is too large
Load Diff
13
web/lib/support-article.ts
Normal file
13
web/lib/support-article.ts
Normal 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()
|
||||||
|
}
|
||||||
127
web/lib/support-conversation-i18n.test.mjs
Normal file
127
web/lib/support-conversation-i18n.test.mjs
Normal 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"
|
||||||
|
)
|
||||||
|
})
|
||||||
46
web/lib/support-conversation-i18n.ts
Normal file
46
web/lib/support-conversation-i18n.ts
Normal 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")
|
||||||
|
}
|
||||||
269
web/lib/support-conversation-list.test.mjs
Normal file
269
web/lib/support-conversation-list.test.mjs
Normal 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)
|
||||||
|
})
|
||||||
199
web/lib/support-conversation-list.ts
Normal file
199
web/lib/support-conversation-list.ts
Normal 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
Reference in New Issue
Block a user