diff --git a/web/components/ui/menubar.tsx b/web/components/ui/menubar.tsx new file mode 100644 index 0000000..9738fb1 --- /dev/null +++ b/web/components/ui/menubar.tsx @@ -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 ( + + ) +} + +function MenubarMenu({ ...props }: React.ComponentProps) { + return +} + +function MenubarGroup({ + ...props +}: React.ComponentProps) { + return +} + +function MenubarPortal({ + ...props +}: React.ComponentProps) { + return +} + +function MenubarTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function MenubarContent({ + className, + align = "start", + alignOffset = -4, + sideOffset = 8, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function MenubarItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function MenubarCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: MenuPrimitive.CheckboxItem.Props & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function MenubarRadioGroup({ + ...props +}: React.ComponentProps) { + return +} + +function MenubarRadioItem({ + className, + children, + inset, + ...props +}: MenuPrimitive.RadioItem.Props & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function MenubarLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function MenubarSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function MenubarShortcut({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function MenubarSub({ + ...props +}: React.ComponentProps) { + return +} + +function MenubarSubTrigger({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function MenubarSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Menubar, + MenubarPortal, + MenubarMenu, + MenubarTrigger, + MenubarContent, + MenubarGroup, + MenubarSeparator, + MenubarLabel, + MenubarItem, + MenubarShortcut, + MenubarCheckboxItem, + MenubarRadioGroup, + MenubarRadioItem, + MenubarSub, + MenubarSubTrigger, + MenubarSubContent, +} diff --git a/web/components/ui/message-scroller.tsx b/web/components/ui/message-scroller.tsx new file mode 100644 index 0000000..be2fb5b --- /dev/null +++ b/web/components/ui/message-scroller.tsx @@ -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 +) { + return +} + +function MessageScroller({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function MessageScrollerViewport({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function MessageScrollerContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function MessageScrollerItem({ + className, + scrollAnchor = false, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function MessageScrollerButton({ + direction = "end", + className, + children, + render, + variant = "secondary", + size = "icon-sm", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + } + {...props} + > + {children ?? ( + <> + + + {direction === "end" ? "Scroll to end" : "Scroll to start"} + + > + )} + + ) +} + +export { + MessageScrollerProvider, + MessageScroller, + MessageScrollerViewport, + MessageScrollerContent, + MessageScrollerItem, + MessageScrollerButton, + useMessageScroller, + useMessageScrollerScrollable, + useMessageScrollerVisibility, +} diff --git a/web/components/ui/message.tsx b/web/components/ui/message.tsx new file mode 100644 index 0000000..53437e8 --- /dev/null +++ b/web/components/ui/message.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function MessageGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function Message({ + className, + align = "start", + ...props +}: React.ComponentProps<"div"> & { align?: "start" | "end" }) { + return ( + + ) +} + +function MessageAvatar({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function MessageContent({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function MessageHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function MessageFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +export { + MessageGroup, + Message, + MessageAvatar, + MessageContent, + MessageFooter, + MessageHeader, +} diff --git a/web/components/ui/popover.tsx b/web/components/ui/popover.tsx new file mode 100644 index 0000000..0b73c6b --- /dev/null +++ b/web/components/ui/popover.tsx @@ -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 +} + +function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) { + return +} + +function PopoverContent({ + className, + align = "center", + alignOffset = 0, + side = "bottom", + sideOffset = 4, + ...props +}: PopoverPrimitive.Popup.Props & + Pick< + PopoverPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + + + ) +} + +function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) { + return ( + + ) +} + +function PopoverDescription({ + className, + ...props +}: PopoverPrimitive.Description.Props) { + return ( + + ) +} + +export { + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} diff --git a/web/components/ui/resizable.tsx b/web/components/ui/resizable.tsx new file mode 100644 index 0000000..c9e767a --- /dev/null +++ b/web/components/ui/resizable.tsx @@ -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 ( + + ) +} + +function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) { + return +} + +function ResizableHandle({ + withHandle, + className, + ...props +}: ResizablePrimitive.SeparatorProps & { + withHandle?: boolean +}) { + return ( + div]:rotate-90", + className + )} + {...props} + > + {withHandle && ( + + )} + + ) +} + +export { ResizableHandle, ResizablePanel, ResizablePanelGroup } diff --git a/web/components/ui/scroll-area.tsx b/web/components/ui/scroll-area.tsx new file mode 100644 index 0000000..84c1e9f --- /dev/null +++ b/web/components/ui/scroll-area.tsx @@ -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 ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: ScrollAreaPrimitive.Scrollbar.Props) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/web/components/ui/select.tsx b/web/components/ui/select.tsx new file mode 100644 index 0000000..e8021f5 --- /dev/null +++ b/web/components/ui/select.tsx @@ -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 ( + + ) +} + +function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { + return ( + + ) +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: SelectPrimitive.Trigger.Props & { + size?: "sm" | "default" +}) { + return ( + + {children} + + } + /> + + ) +} + +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 ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: SelectPrimitive.GroupLabel.Props) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: SelectPrimitive.Item.Props) { + return ( + + + {children} + + + } + > + + + + ) +} + +function SelectSeparator({ + className, + ...props +}: SelectPrimitive.Separator.Props) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/web/components/ui/separator.tsx b/web/components/ui/separator.tsx new file mode 100644 index 0000000..6e1369e --- /dev/null +++ b/web/components/ui/separator.tsx @@ -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 ( + + ) +} + +export { Separator } diff --git a/web/components/ui/sheet.tsx b/web/components/ui/sheet.tsx new file mode 100644 index 0000000..78c7fa3 --- /dev/null +++ b/web/components/ui/sheet.tsx @@ -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 +} + +function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) { + return +} + +function SheetClose({ ...props }: SheetPrimitive.Close.Props) { + return +} + +function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) { + return +} + +function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + showCloseButton = true, + ...props +}: SheetPrimitive.Popup.Props & { + side?: "top" | "right" | "bottom" | "left" + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + } + > + + Close + + )} + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: SheetPrimitive.Description.Props) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/web/components/ui/sidebar.tsx b/web/components/ui/sidebar.tsx new file mode 100644 index 0000000..475071d --- /dev/null +++ b/web/components/ui/sidebar.tsx @@ -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(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( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + + + {children} + + + ) +} + +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 ( + + {children} + + ) + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + + {children} + + + ) + } + + return ( + + {/* This is what handles the sidebar gap on desktop */} + + + + {children} + + + + ) +} + +function SidebarTrigger({ + className, + onClick, + ...props +}: React.ComponentProps) { + const { toggleSidebar } = useSidebar() + + return ( + { + onClick?.(event) + toggleSidebar() + }} + {...props} + > + + Toggle Sidebar + + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar() + + return ( + + ) +} + +function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { + return ( + + ) +} + +function SidebarInput({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function SidebarSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +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 ( + + ) +} + +function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { + return ( + + ) +} + +function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { + return ( + + ) +} + +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 + } & VariantProps) { + const { isMobile, state } = useSidebar() + const comp = useRender({ + defaultTagName: "button", + props: mergeProps<"button">( + { + className: cn(sidebarMenuButtonVariants({ variant, size }), className), + ...(isActive ? { "data-active": "" } : {}), + } as React.ButtonHTMLAttributes & Record, + props + ), + render: !tooltip ? render : , + state: { + slot: "sidebar-menu-button", + sidebar: "menu-button", + size, + active: isActive, + }, + }) + + if (!tooltip) { + return comp + } + + if (typeof tooltip === "string") { + tooltip = { + children: tooltip, + } + } + + return ( + + {comp} + + + ) +} + +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 ( + + ) +} + +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 ( + + {showIcon && ( + + )} + + + ) +} + +function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { + return ( + + ) +} + +function SidebarMenuSubItem({ + className, + ...props +}: React.ComponentProps<"li">) { + return ( + + ) +} + +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 + }) { + 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 : , + 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 ( + + {comp} + + + ) +} + +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, +} diff --git a/web/components/ui/skeleton.tsx b/web/components/ui/skeleton.tsx new file mode 100644 index 0000000..0118624 --- /dev/null +++ b/web/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +export { Skeleton } diff --git a/web/components/ui/sonner.tsx b/web/components/ui/sonner.tsx new file mode 100644 index 0000000..9280ee5 --- /dev/null +++ b/web/components/ui/sonner.tsx @@ -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 ( + + ), + info: ( + + ), + warning: ( + + ), + error: ( + + ), + loading: ( + + ), + }} + 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 } diff --git a/web/components/ui/switch.tsx b/web/components/ui/switch.tsx new file mode 100644 index 0000000..9b8b44b --- /dev/null +++ b/web/components/ui/switch.tsx @@ -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 ( + + + + ) +} + +export { Switch } diff --git a/web/components/ui/table.tsx b/web/components/ui/table.tsx new file mode 100644 index 0000000..8dc13ae --- /dev/null +++ b/web/components/ui/table.tsx @@ -0,0 +1,116 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( + + + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( + + ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( + + ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/web/components/ui/tabs.tsx b/web/components/ui/tabs.tsx new file mode 100644 index 0000000..56c4288 --- /dev/null +++ b/web/components/ui/tabs.tsx @@ -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 ( + + ) +} + +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) { + return ( + + ) +} + +function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { + return ( + + ) +} + +function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } diff --git a/web/components/ui/textarea.tsx b/web/components/ui/textarea.tsx new file mode 100644 index 0000000..04d27f7 --- /dev/null +++ b/web/components/ui/textarea.tsx @@ -0,0 +1,18 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( + + ) +} + +export { Textarea } diff --git a/web/components/ui/toggle-group.tsx b/web/components/ui/toggle-group.tsx new file mode 100644 index 0000000..314f894 --- /dev/null +++ b/web/components/ui/toggle-group.tsx @@ -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 & { + 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 & { + spacing?: number + orientation?: "horizontal" | "vertical" + }) { + return ( + + + {children} + + + ) +} + +function ToggleGroupItem({ + className, + children, + variant = "default", + size = "default", + ...props +}: TogglePrimitive.Props & VariantProps) { + const context = React.useContext(ToggleGroupContext) + + return ( + + {children} + + ) +} + +export { ToggleGroup, ToggleGroupItem } diff --git a/web/components/ui/toggle.tsx b/web/components/ui/toggle.tsx new file mode 100644 index 0000000..555637b --- /dev/null +++ b/web/components/ui/toggle.tsx @@ -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) { + return ( + + ) +} + +export { Toggle, toggleVariants } diff --git a/web/components/ui/tooltip.tsx b/web/components/ui/tooltip.tsx new file mode 100644 index 0000000..69e8a82 --- /dev/null +++ b/web/components/ui/tooltip.tsx @@ -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 ( + + ) +} + +function Tooltip({ ...props }: TooltipPrimitive.Root.Props) { + return +} + +function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { + return +} + +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 ( + + + + {children} + + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/web/components/workbench-header.tsx b/web/components/workbench-header.tsx new file mode 100644 index 0000000..c542727 --- /dev/null +++ b/web/components/workbench-header.tsx @@ -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 ( + + + + + + + + + + + + + + ) +} + +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 ( + <> + + + } + > + + + + {fallback || } + + + + + + + + + + {fallback} + + + {user.name} + + {user.email} + + + + + + + + { + router.push("/dashboard/notifications") + }} + className="gap-2" + > + + {t("nav.notifications")} + {unreadCount > 0 ? ( + + {unreadCount > 99 ? "99+" : unreadCount} + + ) : null} + + { + setChangePasswordOpen(true) + }} + > + + {t("nav.changePassword")} + + + + { + void signOut() + }} + > + + {t("nav.signOut")} + + + + + > + ) +} diff --git a/web/components/workbench-rail.tsx b/web/components/workbench-rail.tsx new file mode 100644 index 0000000..490b3dc --- /dev/null +++ b/web/components/workbench-rail.tsx @@ -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 ( + + ) +} diff --git a/web/components/workspace-switcher.test.mjs b/web/components/workspace-switcher.test.mjs new file mode 100644 index 0000000..7fedc82 --- /dev/null +++ b/web/components/workspace-switcher.test.mjs @@ -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, /[\s\S]*\{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]*/); + 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/); +}); +}); diff --git a/web/components/workspace-switcher.tsx b/web/components/workspace-switcher.tsx new file mode 100644 index 0000000..e7b859f --- /dev/null +++ b/web/components/workspace-switcher.tsx @@ -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([]) + const [activeOrgId, setActiveOrgId] = useState(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" ? ( + <> + + + {currentOrg?.name || t("app.brand")} - {t(currentOption.labelKey)} + + + > + ) : ( + <> + + + {currentOrg?.name || t("app.brand")} + + {t(currentOption.labelKey)} {currentOrg?.role ? `• ${currentOrg.role}` : ""} + + + + {variant === "sidebar" ? ( + + ) : null} + > + ) + + return ( + + + } + > + {triggerContent} + + + {orgs.length > 0 ? ( + <> + + + + Organizations / Workspaces + + {orgs.map((org) => { + const isActive = org.id === activeOrgId + return ( + handleSwitchOrg(org.id)} + > + + {org.name} + + {org.role || "Member"} {org.plan ? `• ${org.plan}` : ""} + + + {isActive ? : null} + + ) + })} + + + > + ) : null} + + + {t("workspace.switchWorkspace")} + {workspaceOptions.map((item) => ( + } + className="cursor-pointer gap-2" + > + + {t(item.labelKey)} + {item.key === currentWorkspace ? ( + + ) : null} + + ))} + + + + ) +} diff --git a/web/e2e/workbench-function.spec.ts b/web/e2e/workbench-function.spec.ts new file mode 100644 index 0000000..fd00624 --- /dev/null +++ b/web/e2e/workbench-function.spec.ts @@ -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([]); + }); +}); diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..0e272ac --- /dev/null +++ b/web/embed.go @@ -0,0 +1,8 @@ +//go:build !dev + +package webspa + +import "embed" + +//go:embed all:out +var SPA embed.FS diff --git a/web/embed_dev.go b/web/embed_dev.go new file mode 100644 index 0000000..de07f26 --- /dev/null +++ b/web/embed_dev.go @@ -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") +} diff --git a/web/embed_test.go b/web/embed_test.go new file mode 100644 index 0000000..ed5febb --- /dev/null +++ b/web/embed_test.go @@ -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) + } +} diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/web/eslint.config.mjs @@ -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; diff --git a/web/hooks/use-agent-conversation-realtime.ts b/web/hooks/use-agent-conversation-realtime.ts new file mode 100644 index 0000000..00cafe2 --- /dev/null +++ b/web/hooks/use-agent-conversation-realtime.ts @@ -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 +type AgentRealtimeEnvelope = { + eventId?: string + type?: string + data?: RealtimeMessageCreatedPayload & + 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(null) + const subscribedConversationIdRef = useRef(null) + const selectedConversationIdRef = useRef(selectedConversationId) + const currentUserIdRef = useRef(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(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) + } + }, []) +} diff --git a/web/hooks/use-lg-media.ts b/web/hooks/use-lg-media.ts new file mode 100644 index 0000000..15a68cf --- /dev/null +++ b/web/hooks/use-lg-media.ts @@ -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); +} diff --git a/web/hooks/use-mobile.ts b/web/hooks/use-mobile.ts new file mode 100644 index 0000000..2b0fe1d --- /dev/null +++ b/web/hooks/use-mobile.ts @@ -0,0 +1,19 @@ +import * as React from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(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 +} diff --git a/web/i18n/config.test.mjs b/web/i18n/config.test.mjs new file mode 100644 index 0000000..c823d44 --- /dev/null +++ b/web/i18n/config.test.mjs @@ -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") +}) diff --git a/web/i18n/config.ts b/web/i18n/config.ts new file mode 100644 index 0000000..b256b70 --- /dev/null +++ b/web/i18n/config.ts @@ -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 = { + 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 +} diff --git a/web/i18n/legal-messages.test.mjs b/web/i18n/legal-messages.test.mjs new file mode 100644 index 0000000..d563b67 --- /dev/null +++ b/web/i18n/legal-messages.test.mjs @@ -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") + } + } +}) \ No newline at end of file diff --git a/web/i18n/message-integrity.test.mjs b/web/i18n/message-integrity.test.mjs new file mode 100644 index 0000000..de5513c --- /dev/null +++ b/web/i18n/message-integrity.test.mjs @@ -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("") + expectingKey = true + } else if (char === "[") { + stack.push({ isArray: true, keys: new Set() }) + trail.push("") + 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, []) +}) \ No newline at end of file diff --git a/web/i18n/messages.ts b/web/i18n/messages.ts new file mode 100644 index 0000000..e280576 --- /dev/null +++ b/web/i18n/messages.ts @@ -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 + +export function translateMessage( + locale: AppLocale, + key: string, + values?: Record +): 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 { + 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)[part] + } + return current +} + +function formatMessage( + message: string, + values?: Record +): string { + if (!values) { + return message + } + return message.replace(/\{(\w+)\}/g, (match, key) => + Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : match + ) +} diff --git a/web/i18n/provider.tsx b/web/i18n/provider.tsx new file mode 100644 index 0000000..527bec8 --- /dev/null +++ b/web/i18n/provider.tsx @@ -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 +} + +const LocaleContext = createContext({ + locale: DEFAULT_LOCALE, + setLocale: () => {}, + t: (key) => key, +}) + +export function AppI18nProvider({ children }: { children: ReactNode }) { + const [locale, setLocaleState] = useState(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( + () => ({ + locale, + t: (key, values) => translateMessage(locale, key, values), + setLocale: () => {}, + }), + [locale] + ) + + if (!isLocaleReady) { + return null + } + + return ( + + {children} + + ) +} + +export function useAppLocale() { + return useContext(LocaleContext) +} + +export function useI18n() { + return useContext(LocaleContext).t +} diff --git a/web/lib/agent-activity-label.test.mjs b/web/lib/agent-activity-label.test.mjs new file mode 100644 index 0000000..2d46d9f --- /dev/null +++ b/web/lib/agent-activity-label.test.mjs @@ -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-ассистент думает...") +}) diff --git a/web/lib/agent-activity-label.ts b/web/lib/agent-activity-label.ts new file mode 100644 index 0000000..2e74699 --- /dev/null +++ b/web/lib/agent-activity-label.ts @@ -0,0 +1,88 @@ +export type AgentActivityLabelOptions = { + stepCode: string + toolName?: string + isCustomer?: boolean +} + +export type TranslateFn = (key: string, values?: Record) => 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") +} diff --git a/web/lib/agent-activity-state.test.mjs b/web/lib/agent-activity-state.test.mjs new file mode 100644 index 0000000..2cebfba --- /dev/null +++ b/web/lib/agent-activity-state.test.mjs @@ -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", + }) +}) diff --git a/web/lib/agent-activity-state.ts b/web/lib/agent-activity-state.ts new file mode 100644 index 0000000..6cb57be --- /dev/null +++ b/web/lib/agent-activity-state.ts @@ -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 } : {}), + } +} diff --git a/web/lib/agent-conversation-realtime.test.mjs b/web/lib/agent-conversation-realtime.test.mjs new file mode 100644 index 0000000..f2b0290 --- /dev/null +++ b/web/lib/agent-conversation-realtime.test.mjs @@ -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 + ) +}) diff --git a/web/lib/agent-conversation-realtime.ts b/web/lib/agent-conversation-realtime.ts new file mode 100644 index 0000000..bb0e043 --- /dev/null +++ b/web/lib/agent-conversation-realtime.ts @@ -0,0 +1,18 @@ +import type { RealtimeConversationPatch } from "@/lib/im-realtime-state" + +const listMembershipFields = new Set([ + "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) + ) +} diff --git a/web/lib/api/admin.ts b/web/lib/api/admin.ts new file mode 100644 index 0000000..89e2455 --- /dev/null +++ b/web/lib/api/admin.ts @@ -0,0 +1,2654 @@ +import { readSession } from "@/lib/auth" +import { request, requestBlob } from "@/lib/api/client" +import { createWebSocketBaseUrl } from "@/lib/api/websocket" +import { translateCurrentMessage } from "@/i18n/messages" + +export type Paging = { + page: number + limit: number + total: number +} + +export type PageResult = { + results: T[] + page: Paging +} + +export type CursorResult = { + results?: T[] | null + cursor: string + hasMore: boolean +} + +export type AdminUser = { + id: number + username: string + nickname: string + avatar: string + mobile?: string + email?: string + userType: string + status: number + isSystem: boolean + lastLoginAt?: string + lastLoginIp?: string + roles?: AdminRole[] + permissions?: string[] +} + +export type UpdateAdminUserPayload = { + id: number + nickname: string + avatar: string + mobile: string | null + email: string | null + remark: string +} + +export type CreateAdminUserPayload = { + username: string + nickname: string + avatar: string + mobile: string | null + email: string | null + remark: string + roleIds: number[] +} + +export type CreateUserResult = { + user: AdminUser + password: string +} + +export type ResetPasswordResult = { + password: string +} + +export type AdminRole = { + id: number + name: string + code: string + status: number + isSystem: boolean + sortNo: number + permissions?: string[] +} + +export type CreateAdminRolePayload = { + name: string + code: string + remark: string +} + +export type AdminPermission = { + id: number + name: string + code: string + type: string + groupName: string + method: string + apiPath: string + status: number + sortNo: number +} + +export type PermissionSyncResult = { + created: number + updated: number + rolePermissionsAdded: number +} + +export type ConversationTag = { + id: number + name: string + color: string +} + +export type ConversationParticipant = { + id: number + participantType: string + participantId: number + externalParticipantId?: string + joinedAt?: string + leftAt?: string + status: number +} + +export type AdminConversation = { + id: number + channelId: number + customerId: number + customerName: string + status: number + serviceMode: number + priority: number + currentAssigneeId: number + currentAssigneeName?: string + lastMessageId: number + lastMessageAt?: string + lastActiveAt?: string + lastMessageSummary?: string + customerUnreadCount: number + agentUnreadCount: number + customerLastReadMessageId: number + customerLastReadAt?: string + agentLastReadMessageId: number + agentLastReadAt?: string + closedAt?: string + closedBy: number + closedByName?: string + closeReason?: string + participants?: ConversationParticipant[] +} + +export type AdminConversationDetail = AdminConversation & { + participants?: ConversationParticipant[] +} + +export type AdminMessage = { + 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 AdminQuickReply = { + id: number + groupName: string + title: string + content: string + status: number + sortNo: number + createdBy: number +} + +export type AdminChannel = { + id: number + channelType: string + channelId: string + aiAgentId: number + aiAgentRolloutPercent: number + previousAiAgentRolloutPercent: number + aiAgentName?: string + name: string + configJson: string + status: number + remark: string +} + +export type CreateAdminChannelPayload = { + channelType: string + aiAgentId: number + aiAgentRolloutPercent: number + name: string + configJson: string + status: number + remark: string +} + +export type UpdateAdminChannelPayload = CreateAdminChannelPayload & { + id: number +} + +export type ResetChannelUserTokenSecretResult = { + userTokenSecret: string +} + +export type AIAgent = { + id: number + name: string + description: string + status: number + statusName: string + aiConfigId: number + aiConfigName?: string + maxSteps: number + contextWindow: number + toolPolicy: string + knowledgePolicy: string + serviceMode: number + serviceModeName: string + systemPrompt: string + welcomeMessage: string + replyTimeoutSeconds: number + rolloutPercent: number + previousRolloutPercent: number + teams: { id: number; name: string }[] + handoffMode: number + handoffModeName: string + fallbackMode: number + fallbackModeName: string + fallbackMessage: string + autoHandoffOnFailure?: boolean + handoffMessage?: string + offHoursMessage?: string + knowledgeBaseIds: number[] + skillIds: number[] + skills: { id: number; name: string }[] + mcpTools: { + toolCode: string + serverCode: string + toolName: string + title: string + description: string + riskLevel: "read" | "write" + requireConfirmation: boolean + arguments?: Record + }[] + workflowBindings: AIAgentWorkflowBinding[] + publishedRevisionId: number + sortNo: number + createdAt: string + updatedAt: string + createUserName: string + updateUserName: string +} + +export type CreateAIAgentPayload = { + name: string + description: string + aiConfigId: number + maxSteps?: number + contextWindow?: number + toolPolicy?: string + knowledgePolicy?: string + serviceMode: number + systemPrompt: string + welcomeMessage: string + replyTimeoutSeconds: number + rolloutPercent: number + teamIds: number[] + handoffMode: number + fallbackMode: number + fallbackMessage: string + autoHandoffOnFailure?: boolean + handoffMessage?: string + offHoursMessage?: string + knowledgeBaseIds: number[] + skillIds: number[] + mcpTools: { + toolCode: string + serverCode: string + toolName: string + title: string + description: string + riskLevel: "read" | "write" + requireConfirmation: boolean + arguments?: Record + }[] + workflowBindings: AIAgentWorkflowBindingInput[] +} + +export type AIAgentWorkflowBinding = { + id: number + workflowId: number + workflowVersionId: number + workflowName: string + workflowVersion: number + toolName: string + triggerInstruction: string + priority: number + enabled: boolean +} + +export type AIAgentWorkflowBindingInput = { + workflowVersionId: number + toolName: string + triggerInstruction: string + priority: number + enabled: boolean +} + +export type UpdateAIAgentPayload = CreateAIAgentPayload & { + id: number +} + +export type AgentRevision = { + id: number + agentId: number + revision: number + status: number + definitionHash: string + publishedAt: string + publishedById: number + publishedByName: string +} + +export type AIWorkflowPosition = { + x: number + y: number +} + +export type AIWorkflowVariableType = + | "string" + | "number" + | "integer" + | "boolean" + | "object" + | "array" + | "array" + | "array" + | "any" + +export type AIWorkflowValue = + | { + type: "ref" + content: [string, string] + } + | { + type: "constant" + content?: unknown + } + | { + type: "template" + content?: string + } + +export type AIWorkflowVariableSpec = { + name: string + label?: string + type: AIWorkflowVariableType + required?: boolean + description: string + operators?: string[] + valueOptions?: { + value: unknown + label: string + description?: string + }[] +} + +export type AIWorkflowDefinition = { + schemaVersion?: number + nodes: AIWorkflowCanvasNode[] + annotations?: AIWorkflowCanvasNode[] + globalVariable?: Record + edges: { + sourceNodeID: string + targetNodeID: string + sourcePortID?: string + targetPortID?: string + }[] +} + +export type AIWorkflowCanvasNode = { + id: string + type: string + meta: { + position: AIWorkflowPosition + } + data: { + title?: string + config?: Record + inputs?: unknown + outputs?: unknown + inputsValues?: Record + [key: string]: unknown + } + blocks?: AIWorkflowCanvasNode[] + edges?: AIWorkflowDefinition["edges"] +} + +export type AIWorkflow = { + id: number + name: string + description: string + status: number + draftDefinition: AIWorkflowDefinition + publishedVersionId: number + sortNo: number + createdAt: string + updatedAt: string + createUserName: string + updateUserName: string +} + +export type AIWorkflowVersion = { + id: number + workflowId: number + version: number + status: number + definition: AIWorkflowDefinition + definitionHash: string + publishedAt: string + publishedById: number + publishedByName: string + createdAt: string + updatedAt: string +} + +export type AIWorkflowNodeSpec = { + type: string + title: string + description: string + icon: string + category: "trigger" | "control" | "ai" | "business" | "utility" + executable: boolean + riskLevel: "low" | "medium" | "high" + interruptible: boolean + requiresConfirmationPredecessor: boolean + configSchema?: unknown + inputSchema?: AIWorkflowVariableSpec[] + outputSchema?: AIWorkflowVariableSpec[] + defaultInputs?: Record +} + +export type AIWorkflowTemplate = { + code: string + name: string + description: string + definition: AIWorkflowDefinition +} + +export type AIWorkflowValidationResult = { + valid: boolean + errors: { + field: string + message: string + }[] +} + +export type AIWorkflowUsage = { + aiAgentId: number + aiAgentName: string + workflowVersionId: number + workflowVersion: number + enabled: boolean +} + +export type CreateAIWorkflowPayload = { + name: string + description: string + definition: AIWorkflowDefinition +} + +export type UpdateAIWorkflowPayload = CreateAIWorkflowPayload & { id: number } + +export type CreateAdminQuickReplyPayload = { + groupName: string + title: string + content: string + status: number + sortNo: number +} + +export type UpdateAdminQuickReplyPayload = CreateAdminQuickReplyPayload & { + id: number +} + +export type SkillDefinition = { + id: number + name: string + description: string + instruction: string + examples: string[] + toolWhitelist: string[] + status: number + statusName: string + remark: string + createdAt: string + updatedAt: string + createUserName: string + updateUserName: string +} + +export type CreateSkillDefinitionPayload = { + name: string + description: string + instruction: string + examples: string[] + toolWhitelist: string[] + remark: string +} + +export type UpdateSkillDefinitionPayload = CreateSkillDefinitionPayload & { + id: number +} + +export type SkillDebugRunPayload = { + aiAgentId: number + conversationId?: number + skillDefinitionId: number + userMessage: string +} + +export type SkillDebugResumePayload = { + aiAgentId: number + conversationId?: number + checkPointId: string + userMessage: string +} + +export type SkillDebugRunResult = { + skillDefinitionId: number + skillName: string + replyText: string + toolWhitelist: string[] + invokedToolCodes: string[] + interruptType: string + checkPointId: string + interrupted: boolean + traceData: string + errorMessage: string + conversationId: number + aiAgentId: number +} + +export type MCPConnectionResult = { + serverCode: string + endpoint: string + protocol: string + serverName: string + version: string +} + +export type MCPServerInfo = { + code: string + enabled: boolean + endpoint: string + timeoutMs: number +} + +export type MCPToolInfo = { + name: string + title: string + description: string + inputSchema: unknown + outputSchema?: unknown +} + +export type MCPToolSourceType = "mcp" | "graph" | "builtin" + +export type MCPToolCatalogItem = { + toolCode: string + serverCode: string + toolName: string + sourceType: MCPToolSourceType + autoInjected: boolean + title: string + description: string + inputSchema: unknown + outputSchema?: unknown + riskLevel: "read" | "write" + requireConfirmation: boolean + riskEditable: boolean +} + +export type MCPToolResultContent = { + type: string + text?: string + data?: unknown +} + +export type MCPToolCallResult = { + serverCode: string + toolName: string + isError: boolean + content: MCPToolResultContent[] + structuredContent?: unknown +} + +export type AIWorkflowNodeRun = { + id: number + workflowRunId: number + nodeId: string + nodeType: string + status: number + statusName: string + inputPreview: string + outputPreview: string + errorMessage: string + startedAt: string + endedAt: string + durationMs: number +} + +export type AIWorkflowRun = { + id: number + workflowId: number + workflowVersionId: number + workflowVersion: number + workflowName: string + conversationId: number + aiAgentId: number + aiAgentName: string + messageId: number + status: number + statusName: string + startedAt: string + endedAt: string + durationMs: number + interruptType: string + interruptNodeId: string + errorMessage: string + createdAt: string + updatedAt: string + definition?: AIWorkflowDefinition + nodes?: AIWorkflowNodeRun[] +} + +export type AgentRun = { + id: number + conversationId: number + aiAgentId: number + agentRevisionId: number + sourceMessageId: number + workflowRunId: number + status: string + promptTokens: number + completionTokens: number + startedAt: string + endedAt: string + durationMs: number + errorMessage: string + traceData: string + createdAt: string + updatedAt: string + steps?: AgentStep[] + toolCalls?: AgentToolCall[] + qualityFeedback?: AgentRunQualityFeedback +} + +export type AgentRunQualityFeedback = { + id: number + agentRunId: number + resolutionStatus: "unknown" | "resolved" | "unresolved" + evidenceStatus: "unknown" | "supported" | "unsupported" + comment: string + updateUserName: string + updatedAt: string +} + +export type AgentRunMetrics = { + totalRuns: number + completedRuns: number + failedRuns: number + interruptedRuns: number + completionRate: number + toolCalls: number + toolSuccessRate: number + averageSteps: number + averageDurationMs: number + p95DurationMs: number + promptTokens: number + completionTokens: number + handoffRate: number + knowledgeFallbackRate: number + resumedInterrupts: number + resolvedInterrupts: number + interruptRecoveryRate: number + reviewedRuns: number + resolvedRuns: number + resolutionRate: number + unsupportedEvidenceRuns: number + unsupportedEvidenceRate: number +} + +export type AgentEvaluationCase = { + id: string + category?: string + message: string + history?: string[] + expect?: Record +} + +export type AgentEvaluationReport = { + total: number + passed: number + results: { + caseId: string + category: string + passed: boolean + replyText: string + interrupted: boolean + error?: string + finding?: string + }[] + csv: string +} + +export type AgentStep = { + id: number + agentRunId: number + stepType: string + stepCode: string + status: string + inputPreview: string + outputPreview: string + errorMessage: string + startedAt: string + endedAt: string + durationMs: number +} + +export type AgentToolCall = { + id: number + agentRunId: number + agentStepId: number + toolCode: string + riskLevel: string + requireConfirm: boolean + status: string + argumentsPreview: string + resultPreview: string + errorMessage: string + durationMs: number + createdAt: string +} + +export type AdminAgentProfile = { + id: number + userId: number + teamId: number + teamName?: string + username?: string + nickname?: string + agentCode: string + displayName: string + avatar: string + serviceStatus: number + maxConcurrentCount: number + priorityLevel: number + autoAssignEnabled: boolean + receiveOfflineMessage: boolean + lastOnlineAt?: string + lastStatusAt?: string + remark: string +} + +export type CreateAdminAgentProfilePayload = { + userId: number + teamId: number + agentCode: string + displayName: string + avatar: string + serviceStatus: number + maxConcurrentCount: number + priorityLevel: number + autoAssignEnabled: boolean + receiveOfflineMessage: boolean + lastOnlineAt?: string + lastStatusAt?: string + remark: string +} + +export type UpdateAdminAgentProfilePayload = + CreateAdminAgentProfilePayload & { + id: number + } + +export type AdminAgentTeam = { + id: number + name: string + leaderUserId: number + leaderUsername?: string + leaderNickname?: string + status: number + description: string + remark: string +} + +export type CreateAdminAgentTeamPayload = { + name: string + leaderUserId: number + status: number + description: string + remark: string +} + +export type UpdateAdminAgentTeamPayload = CreateAdminAgentTeamPayload & { + id: number +} + +export type AdminAgentTeamSchedule = { + id: number + teamId: number + teamName?: string + startAt: string + endAt: string + remark: string +} + +export type CreateAdminAgentTeamSchedulePayload = { + teamId: number + startAt: string + endAt: string + remark: string +} + +export type UpdateAdminAgentTeamSchedulePayload = + CreateAdminAgentTeamSchedulePayload & { + id: number + } + +export type BatchAdminAgentTeamSchedulePayload = { + teamIds: number[] + startDate: string + endDate: string + weekdays: number[] + startTime: string + endTime: string + remark: string +} + +export type AdminAgentTeamScheduleBatchPreviewItem = { + teamId: number + teamName: string + date: string + weekday: number + startAt: string + endAt: string + remark: string + conflict: boolean + conflictReason: string +} + +export type AdminAgentTeamScheduleBatchPreview = { + total: number + conflict: boolean + items: AdminAgentTeamScheduleBatchPreviewItem[] +} + +export type AdminAgentTeamScheduleBatchGenerateResult = { + created: number +} + +function toQueryString(query?: Record) { + 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 createAdminWebSocketUrl() { + const session = readSession() + if (!session?.accessToken) { + throw new Error(translateCurrentMessage("api.authExpired")) + } + + const baseUrl = createWebSocketBaseUrl() + const params = new URLSearchParams({ + accessToken: session.accessToken, + }) + return `${baseUrl}/api/ws/dashboard?${params.toString()}` +} + +export function fetchChannels( + query?: Record +) { + return request>( + `/api/dashboard/channel/list${toQueryString(query)}` + ) +} + +export function fetchChannel(id: number) { + return request(`/api/dashboard/channel/${id}`) +} + +export function createChannel(payload: CreateAdminChannelPayload) { + return request("/api/dashboard/channel/create", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function updateChannel(payload: UpdateAdminChannelPayload) { + return request("/api/dashboard/channel/update", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function rollbackChannelAIAgentRollout(id: number) { + return request("/api/dashboard/channel/rollback_ai_agent_rollout", { + method: "POST", + body: JSON.stringify({ id }), + }) +} + +export function updateChannelStatus(id: number, status: number) { + return request("/api/dashboard/channel/update_status", { + method: "POST", + body: JSON.stringify({ id, status }), + }) +} + +export function resetChannelUserTokenSecret(id: number) { + return request( + "/api/dashboard/channel/reset_user_token_secret", + { + method: "POST", + body: JSON.stringify({ id }), + } + ) +} + +export function deleteChannel(id: number) { + return request("/api/dashboard/channel/delete", { + method: "POST", + body: JSON.stringify({ id }), + }) +} + +export function fetchAIAgents( + query?: Record +) { + return request>( + `/api/dashboard/ai-agent/list${toQueryString(query)}` + ) +} + +export function fetchAIAgentsAll(query?: Record) { + return request( + `/api/dashboard/ai-agent/list_all${toQueryString(query)}` + ) +} + +export function fetchAIAgent(id: number) { + return request(`/api/dashboard/ai-agent/${id}`) +} + +export function createAIAgent(payload: CreateAIAgentPayload) { + return request("/api/dashboard/ai-agent/create", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function updateAIAgent(payload: UpdateAIAgentPayload) { + return request("/api/dashboard/ai-agent/update", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function publishAIAgent(id: number) { + return request("/api/dashboard/ai-agent/publish", { + method: "POST", + body: JSON.stringify({ id }), + }) +} + +export function fetchAIAgentRevisions(id: number) { + return request(`/api/dashboard/ai-agent/${id}/revision/list`) +} + +export function rollbackAIAgent(id: number, revisionId: number) { + return request("/api/dashboard/ai-agent/rollback", { + method: "POST", + body: JSON.stringify({ id, revisionId }), + }) +} + +export function rollbackAIAgentRollout(id: number) { + return request("/api/dashboard/ai-agent/rollback_rollout", { + method: "POST", + body: JSON.stringify({ id }), + }) +} + +export function deleteAIAgent(id: number) { + return request("/api/dashboard/ai-agent/delete", { + method: "POST", + body: JSON.stringify({ id }), + }) +} + +export function updateAIAgentSort(ids: number[]) { + return request("/api/dashboard/ai-agent/update_sort", { + method: "POST", + body: JSON.stringify(ids), + }) +} + +export function updateAIAgentStatus(id: number, status: number) { + return request("/api/dashboard/ai-agent/update_status", { + method: "POST", + body: JSON.stringify({ id, status }), + }) +} + +export function fetchAIWorkflows(query?: Record) { + return request>(`/api/dashboard/ai-workflow/list${toQueryString(query)}`) +} + +export function fetchAIWorkflow(id: number) { + return request(`/api/dashboard/ai-workflow/${id}`) +} + +export function createAIWorkflow(payload: CreateAIWorkflowPayload) { + return request("/api/dashboard/ai-workflow/create", { method: "POST", body: JSON.stringify(payload) }) +} + +export function updateAIWorkflow(payload: UpdateAIWorkflowPayload) { + return request("/api/dashboard/ai-workflow/update", { method: "POST", body: JSON.stringify(payload) }) +} + +export function deleteAIWorkflow(id: number) { + return request("/api/dashboard/ai-workflow/delete", { method: "POST", body: JSON.stringify({ id }) }) +} + +export function fetchAIWorkflowUsage(id: number) { + return request(`/api/dashboard/ai-workflow/${id}/usage`) +} + +export function restoreAIWorkflowVersion(workflowId: number, workflowVersionId: number) { + return request("/api/dashboard/ai-workflow/restore-version", { method: "POST", body: JSON.stringify({ workflowId, workflowVersionId }) }) +} + +export function fetchAIWorkflowNodeSpecs() { + return request("/api/dashboard/ai-workflow/node-spec/list") +} + +export function fetchAIWorkflowDefaultDefinition() { + return request("/api/dashboard/ai-workflow/default-definition") +} + +export function fetchAIWorkflowTemplates() { + return request("/api/dashboard/ai-workflow/template/list") +} + +export function fetchAIWorkflowVersions(query?: Record) { + return request>( + `/api/dashboard/ai-workflow/version/list${toQueryString(query)}` + ) +} + +export function validateAIWorkflow(definition: AIWorkflowDefinition) { + return request("/api/dashboard/ai-workflow/validate", { + method: "POST", + body: JSON.stringify({ definition }), + }) +} + +export function publishAIWorkflow(workflowId: number, definition: AIWorkflowDefinition) { + return request("/api/dashboard/ai-workflow/publish", { + method: "POST", + body: JSON.stringify({ workflowId, definition }), + }) +} + +export function fetchUsers(query?: Record) { + return request>( + `/api/dashboard/user/list${toQueryString(query)}` + ) +} + +export function fetchUsersAll(query?: Record) { + return request( + `/api/dashboard/user/list_all${toQueryString(query)}` + ) +} + +export type SMTPProbeResult = { + host: string + port: number + provider: string + capabilities: string[] + starttlsOffered: boolean + tlsEstablished: boolean + authAccepted: boolean + authAttempted: boolean +} + +export function testChannelEmail(channelId: number) { + return request("/api/dashboard/channel/test_email", { + method: "POST", + body: JSON.stringify({ id: channelId }), + }) +} + +export type DashboardBranding = { + companyName: string + companyLogoUrl: string + savedCompanyName: string + savedCompanyLogoUrl: string + configuredCompanyName: string + configuredCompanyLogoUrl: string + effectiveCompanyName: string + usesConfiguredFallback: boolean +} + +export function fetchDashboardBranding() { + return request("/api/dashboard/branding") +} + +export function saveDashboardBranding(payload: { + companyName: string + companyLogoUrl: string +}) { + return request("/api/dashboard/branding/save", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function createUser(payload: CreateAdminUserPayload) { + return request("/api/dashboard/user/create", { + method: "POST", + body: JSON.stringify({ + username: payload.username, + nickname: payload.nickname, + avatar: payload.avatar, + mobile: payload.mobile, + email: payload.email, + remark: payload.remark, + roleIds: payload.roleIds, + }), + }) +} + +export function updateUser(payload: UpdateAdminUserPayload) { + return request("/api/dashboard/user/update", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function fetchUserDetail(id: number) { + return request(`/api/dashboard/user/${id}`) +} + +export function updateUserStatus(id: number, status: number) { + return request("/api/dashboard/user/update_status", { + method: "POST", + body: JSON.stringify({ id, status }), + }) +} + +export function resetUserPassword(userId: number) { + return request("/api/dashboard/user/reset_password", { + method: "POST", + body: JSON.stringify({ userId }), + }) +} + +export function changeSelfPassword(password: string) { + return request("/api/dashboard/user/change_password", { + method: "POST", + body: JSON.stringify({ password }), + }) +} + +export function assignUserRoles(userId: number, roleIds: number[]) { + return request("/api/dashboard/user/assign_role", { + method: "POST", + body: JSON.stringify({ userId, roleIds }), + }) +} + +export function fetchRoles(query?: Record) { + return request>( + `/api/dashboard/role/list${toQueryString(query)}` + ) +} + +export function fetchRoleListAll() { + return request("/api/dashboard/role/list_all") +} + +export function fetchRoleDetail(id: number) { + return request(`/api/dashboard/role/${id}`) +} + +export function createRole(payload: CreateAdminRolePayload) { + return request("/api/dashboard/role/create", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function assignRolePermissions(roleId: number, permissionIds: number[]) { + return request("/api/dashboard/role/assign_permission", { + method: "POST", + body: JSON.stringify({ roleId, permissionIds }), + }) +} + +export function updateRoleSort(ids: number[]) { + return request("/api/dashboard/role/update_sort", { + method: "POST", + body: JSON.stringify(ids), + }) +} + +export function fetchPermissions( + query?: Record +) { + return request>( + `/api/dashboard/permission/list${toQueryString(query)}` + ) +} + +export function syncPermissions() { + return request("/api/dashboard/permission/sync", { + method: "POST", + }) +} + +export function fetchConversations( + query?: Record +) { + return request>( + `/api/dashboard/conversation/list${toQueryString(query)}` + ) +} + +export function fetchConversationDetail(id: number) { + return request(`/api/dashboard/conversation/${id}`) +} + +export function fetchConversationMessages( + query?: Record +) { + return request>( + `/api/dashboard/conversation/message_list${toQueryString(query)}` + ) +} + +export function assignConversation( + conversationId: number, + assigneeId: number, + reason: string +) { + return request("/api/dashboard/conversation/assign", { + method: "POST", + body: JSON.stringify({ conversationId, assigneeId, reason }), + }) +} + +export function dispatchConversation(conversationId: number) { + return request("/api/dashboard/conversation/dispatch", { + method: "POST", + body: JSON.stringify({ conversationId }), + }) +} + +export function transferConversation( + conversationId: number, + toUserId: number, + reason: string +) { + return request("/api/dashboard/conversation/transfer", { + method: "POST", + body: JSON.stringify({ conversationId, toUserId, reason }), + }) +} + +export function closeConversation(conversationId: number, closeReason: string) { + return request("/api/dashboard/conversation/close", { + method: "POST", + body: JSON.stringify({ conversationId, closeReason }), + }) +} + +export function markConversationRead(conversationId: number, messageId = 0) { + return request("/api/dashboard/conversation/read", { + method: "POST", + body: JSON.stringify({ conversationId, messageId }), + }) +} + +export function sendConversationMessage(payload: { + conversationId: number + messageType: string + content: string + payload?: string + clientMsgId?: string +}) { + return request("/api/dashboard/conversation/send_message", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function recallConversationMessage(messageId: number) { + return request("/api/dashboard/conversation/recall_message", { + method: "POST", + body: JSON.stringify({ messageId }), + }) +} + +export function fetchQuickReplies( + query?: Record +) { + return request>( + `/api/dashboard/quick-reply/list${toQueryString(query)}` + ) +} + +export function fetchQuickReplyListAll() { + return request("/api/dashboard/quick-reply/list_all") +} + +export function fetchQuickReply(id: number) { + return request(`/api/dashboard/quick-reply/${id}`) +} + +export function createQuickReply(payload: CreateAdminQuickReplyPayload) { + return request("/api/dashboard/quick-reply/create", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function updateQuickReply(payload: UpdateAdminQuickReplyPayload) { + return request("/api/dashboard/quick-reply/update", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function deleteQuickReply(id: number) { + return request("/api/dashboard/quick-reply/delete", { + method: "POST", + body: JSON.stringify({ id }), + }) +} + +export function fetchSkillDefinitions( + query?: Record +) { + return request>( + `/api/dashboard/skill-definition/list${toQueryString(query)}` + ) +} + +export function fetchSkillDefinitionsAll( + query?: Record +) { + return request( + `/api/dashboard/skill-definition/list_all${toQueryString(query)}` + ) +} + +export function fetchSkillDefinition(id: number) { + return request(`/api/dashboard/skill-definition/${id}`) +} + +export function createSkillDefinition(payload: CreateSkillDefinitionPayload) { + return request("/api/dashboard/skill-definition/create", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function updateSkillDefinition(payload: UpdateSkillDefinitionPayload) { + return request("/api/dashboard/skill-definition/update", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function fetchAIWorkflowRuns( + query?: Record +) { + return request>( + `/api/dashboard/ai-workflow/run/list${toQueryString(query)}` + ) +} + +export function fetchAIWorkflowRun(id: number) { + return request(`/api/dashboard/ai-workflow/run/${id}`) +} + +export function fetchAgentRuns(query?: Record) { + return request>( + `/api/dashboard/agent-run/list${toQueryString(query)}` + ) +} + +export function fetchAgentRun(id: number) { + return request(`/api/dashboard/agent-run/${id}`) +} + +export function fetchAgentRunMetrics(aiAgentId?: number) { + return request(`/api/dashboard/agent-run/metrics${toQueryString(aiAgentId ? { aiAgentId } : undefined)}`) +} + +export function runAgentEvaluation(payload: { aiAgentId: number; cases: AgentEvaluationCase[] }) { + return request("/api/dashboard/agent-run/evaluate", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function saveAgentRunQualityFeedback(payload: { + agentRunId: number + resolutionStatus: AgentRunQualityFeedback["resolutionStatus"] + evidenceStatus: AgentRunQualityFeedback["evidenceStatus"] + comment: string +}) { + return request("/api/dashboard/agent-run/quality_feedback", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function updateSkillDefinitionStatus(id: number, status: number) { + return request("/api/dashboard/skill-definition/update_status", { + method: "POST", + body: JSON.stringify({ id, status }), + }) +} + +export function deleteSkillDefinition(id: number) { + return request("/api/dashboard/skill-definition/delete", { + method: "POST", + body: JSON.stringify({ id }), + }) +} + +export function restoreSkillDefinition(id: number) { + return request("/api/dashboard/skill-definition/restore", { + method: "POST", + body: JSON.stringify({ id }), + }) +} + +export function debugRunSkillDefinition(payload: SkillDebugRunPayload) { + return request("/api/dashboard/skill-definition/debug_run", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function debugResumeSkillDefinition(payload: SkillDebugResumePayload) { + return request("/api/dashboard/skill-definition/debug_resume", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function testMCPConnection(serverCode: string) { + return request("/api/dashboard/mcp/test_connection", { + method: "POST", + body: JSON.stringify({ serverCode }), + }) +} + +export function listMCPServers() { + return request("/api/dashboard/mcp/list_servers") +} + +export function listMCPTools(serverCode: string) { + return request("/api/dashboard/mcp/list_tools", { + method: "POST", + body: JSON.stringify({ serverCode }), + }) +} + +export function fetchMCPCatalog() { + return request("/api/dashboard/mcp/catalog") +} + +export function callMCPTool(payload: { + serverCode: string + toolName: string + arguments: Record +}) { + return request("/api/dashboard/mcp/call_tool", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function fetchAgentProfiles( + query?: Record +) { + return request>( + `/api/dashboard/agent/list${toQueryString(query)}` + ) +} + +export function fetchAgentProfile(id: number) { + return request(`/api/dashboard/agent/${id}`) +} + +export function fetchAgentProfilesAll( + query?: Record +) { + return request( + `/api/dashboard/agent/list_all${toQueryString(query)}` + ) +} + +export function createAgentProfile(payload: CreateAdminAgentProfilePayload) { + return request("/api/dashboard/agent/create", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function updateAgentProfile(payload: UpdateAdminAgentProfilePayload) { + return request("/api/dashboard/agent/update", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function deleteAgentProfile(id: number) { + return request("/api/dashboard/agent/delete", { + method: "POST", + body: JSON.stringify({ id }), + }) +} + +export function fetchAgentTeams(query?: Record) { + return request( + `/api/dashboard/agent-team/list${toQueryString(query)}` + ) +} + +export function fetchAgentTeamsAll() { + return request( + `/api/dashboard/agent-team/list_all` + ) +} + +export function fetchAgentTeam(id: number) { + return request(`/api/dashboard/agent-team/${id}`) +} + +export function createAgentTeam(payload: CreateAdminAgentTeamPayload) { + return request("/api/dashboard/agent-team/create", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function updateAgentTeam(payload: UpdateAdminAgentTeamPayload) { + return request("/api/dashboard/agent-team/update", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function deleteAgentTeam(id: number) { + return request("/api/dashboard/agent-team/delete", { + method: "POST", + body: JSON.stringify({ id }), + }) +} + +export function fetchAgentTeamSchedules( + query?: Record +) { + return request>( + `/api/dashboard/agent-team-schedule/list${toQueryString(query)}` + ) +} + +export function fetchAgentTeamScheduleCalendar( + query: Record +) { + return request( + `/api/dashboard/agent-team-schedule/calendar${toQueryString(query)}` + ) +} + +export function fetchAgentTeamSchedule(id: number) { + return request(`/api/dashboard/agent-team-schedule/${id}`) +} + +export function createAgentTeamSchedule(payload: CreateAdminAgentTeamSchedulePayload) { + return request("/api/dashboard/agent-team-schedule/create", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function updateAgentTeamSchedule(payload: UpdateAdminAgentTeamSchedulePayload) { + return request("/api/dashboard/agent-team-schedule/update", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function deleteAgentTeamSchedule(id: number) { + return request("/api/dashboard/agent-team-schedule/delete", { + method: "POST", + body: JSON.stringify({ id }), + }) +} + +export function previewAgentTeamScheduleBatch(payload: BatchAdminAgentTeamSchedulePayload) { + return request( + "/api/dashboard/agent-team-schedule/batch_preview", + { + method: "POST", + body: JSON.stringify(payload), + } + ) +} + +export function generateAgentTeamScheduleBatch(payload: BatchAdminAgentTeamSchedulePayload) { + return request( + "/api/dashboard/agent-team-schedule/batch_generate", + { + method: "POST", + body: JSON.stringify(payload), + } + ) +} + +export type AIConfig = { + id: number + name: string + provider: string + baseUrl: string + hasApiKey: boolean + modelType: string + modelName: string + dimension: number + maxContextTokens: number + maxOutputTokens: number + timeoutMs: number + maxRetryCount: number + rpmLimit: number + tpmLimit: number + status: number + sortNo: number + remark: string +} + +export type CreateAIConfigPayload = { + name: string + provider: string + baseUrl: string + apiKey: string + modelType: string + modelName: string + dimension: number + maxContextTokens: number + maxOutputTokens: number + timeoutMs: number + maxRetryCount: number + rpmLimit: number + tpmLimit: number + remark: string +} + +export type UpdateAIConfigPayload = CreateAIConfigPayload & { + id: number +} + +export function fetchAIConfigs( + query?: Record +) { + return request>( + `/api/dashboard/ai-config/list${toQueryString(query)}` + ) +} + +export function fetchAIConfig(id: number) { + return request(`/api/dashboard/ai-config/${id}`) +} + +export function fetchAIConfigsAll( + query?: Record +) { + return request( + `/api/dashboard/ai-config/list_all${toQueryString(query)}` + ) +} + +export function createAIConfig(payload: CreateAIConfigPayload) { + return request("/api/dashboard/ai-config/create", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function updateAIConfig(payload: UpdateAIConfigPayload) { + return request("/api/dashboard/ai-config/update", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function deleteAIConfig(id: number) { + return request("/api/dashboard/ai-config/delete", { + method: "POST", + body: JSON.stringify({ id }), + }) +} + +export function updateAIConfigStatus(id: number, status: number) { + return request("/api/dashboard/ai-config/update_status", { + method: "POST", + body: JSON.stringify({ id, status }), + }) +} + +export function updateAIConfigSort(ids: number[]) { + return request("/api/dashboard/ai-config/update_sort", { + method: "POST", + body: JSON.stringify(ids), + }) +} + +export type KnowledgeBase = { + id: number + name: string + description: string + knowledgeType: string + knowledgeTypeName: string + status: number + statusName: string + defaultTopK: number + defaultScoreThreshold: number + defaultRerankLimit: number + chunkProvider: string + chunkTargetTokens: number + chunkMaxTokens: number + chunkOverlapTokens: number + answerMode: number + answerModeName: string + documentCount: number + faqCount: number + remark: string + createdAt: string + updatedAt: string + createUserName: string + updateUserName: string +} + +export type CreateKnowledgeBasePayload = { + name: string + description: string + knowledgeType: string + defaultTopK: number + defaultScoreThreshold: number + defaultRerankLimit: number + chunkProvider: string + chunkTargetTokens: number + chunkMaxTokens: number + chunkOverlapTokens: number + answerMode: number + remark: string +} + +export type UpdateKnowledgeBasePayload = CreateKnowledgeBasePayload & { + id: number +} + +export type KnowledgeDocument = { + id: number + knowledgeBaseId: number + knowledgeBaseName?: string + directoryId: number + directoryName?: string + directoryPath?: string + title: string + contentType: string + content: string + status: number + statusName: string + indexStatus: string + indexStatusName: string + indexedAt?: string | null + indexError: string + contentHash: string + createdAt: string + updatedAt: string + createUserName: string + updateUserName: string +} + +export type KnowledgeDocumentListItem = Omit + +export type KnowledgeFAQ = { + id: number + knowledgeBaseId: number + knowledgeBaseName?: string + directoryId: number + directoryName?: string + directoryPath?: string + question: string + answer: string + similarQuestions: string[] + status: number + statusName: string + indexStatus: string + indexStatusName: string + indexedAt?: string | null + indexError: string + remark: string + createdAt: string + updatedAt: string + createUserName: string + updateUserName: string +} + +export type KnowledgeSearchResult = { + knowledgeBaseId: number + chunkId: number + documentId: number + documentTitle: string + faqId: number + faqQuestion: string + chunkNo: number + title: string + sectionPath: string + content: string + score: number + rerankScore?: number +} + +export type KnowledgeSearchResponse = { + question: string + results: KnowledgeSearchResult[] + hitCount: number + latencyMs: number +} + +export type KnowledgeAnswerResponse = { + question: string + rewriteQuestion?: string + answer: string + answerStatus: number + answerStatusName: string + citations: KnowledgeCitation[] + hits: KnowledgeSearchResult[] + hitCount: number + topScore: number + latencyMs: number + retrieveMs: number + generateMs: number + promptTokens: number + completionTokens: number + modelName: string + retrieveLogId: number +} + +export type KnowledgeCitation = { + documentId: number + documentTitle: string + faqId: number + faqQuestion: string + chunkNo: number + title: string + sectionPath: string + snippet: string + score: number +} + +export type KnowledgeRetrieveLog = { + id: number + knowledgeBaseId: number + knowledgeBaseName?: string + channel: string + channelName: string + scene: string + sceneName: string + sessionId: string + conversationId: number + requestId: string + question: string + rewriteQuestion: string + answer: string + answerStatus: number + answerStatusName: string + hitCount: number + topScore: number + chunkProvider: string + chunkTargetTokens: number + chunkMaxTokens: number + chunkOverlapTokens: number + rerankEnabled: boolean + rerankLimit: number + citationCount: number + usedChunkCount: number + latencyMs: number + retrieveMs: number + generateMs: number + promptTokens: number + completionTokens: number + modelName: string + traceData: string + createdAt: string +} + +export type KnowledgeRetrieveHit = { + id: number + retrieveLogId: number + knowledgeBaseId: number + chunkId: number + documentId: number + documentTitle: string + faqId: number + faqQuestion: string + chunkNo: number + title: string + sectionPath: string + chunkType: string + chunkTypeName: string + provider: string + rankNo: number + score: number + rerankScore: number + usedInAnswer: boolean + isCitation: boolean + snippet: string + createdAt: string +} + +export type KnowledgeRetrieveLogDetail = { + log: KnowledgeRetrieveLog + hits: KnowledgeRetrieveHit[] +} + +export type KnowledgeRetrieveLogListQuery = { + knowledgeBaseId: number + question?: string + channel?: string + scene?: string + answerStatus?: number + chunkProvider?: string + rerankEnabled?: number + page?: number + limit?: number +} + +export type KnowledgeSearchPayload = { + knowledgeBaseIds: number[] + question: string + topK?: number + scoreThreshold?: number + rerankLimit?: number +} + +export type KnowledgeAnswerPayload = KnowledgeSearchPayload & { + answerMode?: number +} + +export type CreateKnowledgeDocumentPayload = { + knowledgeBaseId: number + directoryId: number + title: string + contentType: string + content: string +} + +export type UpdateKnowledgeDocumentPayload = CreateKnowledgeDocumentPayload & { + id: number +} + +export type CreateKnowledgeFAQPayload = { + knowledgeBaseId: number + directoryId: number + question: string + answer: string + similarQuestions: string[] + remark: string +} + +export type UpdateKnowledgeFAQPayload = CreateKnowledgeFAQPayload & { + id: number +} + +export type BatchMoveKnowledgeContentPayload = { + knowledgeBaseId: number + directoryId: number + ids: number[] +} + +export type KnowledgeDirectory = { + id: number + knowledgeBaseId: number + parentId: number + name: string + sortNo: number + status: number + statusName: string + remark: string + createdAt: string + updatedAt: string + createUserName: string + updateUserName: string + children: KnowledgeDirectory[] +} + +export type CreateKnowledgeDirectoryPayload = { + knowledgeBaseId: number + parentId: number + name: string + remark: string +} + +export type UpdateKnowledgeDirectoryPayload = CreateKnowledgeDirectoryPayload & { + id: number +} + +export type KnowledgeFAQImportMode = "append" | "overwrite" + +export type KnowledgeFAQImportError = { + row: number + message: string +} + +export type KnowledgeFAQImportResult = { + total: number + created: number + updated: number + skipped: number + failed: number + errors: KnowledgeFAQImportError[] +} + +export function fetchKnowledgeBases( + query?: Record +) { + return request>( + `/api/dashboard/knowledge-base/list${toQueryString(query)}` + ) +} + +export function fetchKnowledgeBasesAll( + query?: Record +) { + return request( + `/api/dashboard/knowledge-base/list_all${toQueryString(query)}` + ) +} + +export function fetchKnowledgeBase(id: number) { + return request(`/api/dashboard/knowledge-base/${id}`) +} + +export function createKnowledgeBase(payload: CreateKnowledgeBasePayload) { + return request("/api/dashboard/knowledge-base/create", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function updateKnowledgeBase(payload: UpdateKnowledgeBasePayload) { + return request("/api/dashboard/knowledge-base/update", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function deleteKnowledgeBase(id: number) { + return request("/api/dashboard/knowledge-base/delete", { + method: "POST", + body: JSON.stringify({ id }), + }) +} + +export function updateKnowledgeBaseSort(ids: number[]) { + return request("/api/dashboard/knowledge-base/update_sort", { + method: "POST", + body: JSON.stringify(ids), + }) +} + +export function rebuildKnowledgeBaseIndex(id: number) { + return request("/api/dashboard/knowledge-base/rebuild_index", { + method: "POST", + body: JSON.stringify({ id }), + }) +} + +export function fetchKnowledgeDirectories(knowledgeBaseId: number) { + return request( + `/api/dashboard/knowledge-directory/list_all${toQueryString({ knowledgeBaseId })}` + ) +} + +export function createKnowledgeDirectory(payload: CreateKnowledgeDirectoryPayload) { + return request("/api/dashboard/knowledge-directory/create", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function updateKnowledgeDirectory(payload: UpdateKnowledgeDirectoryPayload) { + return request("/api/dashboard/knowledge-directory/update", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function deleteKnowledgeDirectory(id: number) { + return request("/api/dashboard/knowledge-directory/delete", { + method: "POST", + body: JSON.stringify({ id }), + }) +} + +export function updateKnowledgeDirectorySort(payload: { + knowledgeBaseId: number + parentId: number + ids: number[] +}) { + return request("/api/dashboard/knowledge-directory/update_sort", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function fetchKnowledgeDocuments( + query?: Record +) { + return request>( + `/api/dashboard/knowledge-document/list${toQueryString(query)}` + ) +} + +export function fetchKnowledgeDocument(id: number) { + return request(`/api/dashboard/knowledge-document/${id}`) +} + +export function createKnowledgeDocument(payload: CreateKnowledgeDocumentPayload) { + return request("/api/dashboard/knowledge-document/create", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function updateKnowledgeDocument(payload: UpdateKnowledgeDocumentPayload) { + return request("/api/dashboard/knowledge-document/update", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function deleteKnowledgeDocument(id: number) { + return request("/api/dashboard/knowledge-document/delete", { + method: "POST", + body: JSON.stringify({ id }), + }) +} + +export function batchMoveKnowledgeDocuments(payload: BatchMoveKnowledgeContentPayload) { + return request("/api/dashboard/knowledge-document/batch_move", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function batchDeleteKnowledgeDocuments(ids: number[]) { + return request("/api/dashboard/knowledge-document/batch_delete", { + method: "POST", + body: JSON.stringify({ ids }), + }) +} + +export function buildKnowledgeDocumentIndex(documentId: number) { + return request("/api/dashboard/knowledge-retrieve/build", { + method: "POST", + body: JSON.stringify({ documentId }), + }) +} + +export function fetchKnowledgeFAQs( + query?: Record +) { + return request>( + `/api/dashboard/knowledge-faq/list${toQueryString(query)}` + ) +} + +export function fetchKnowledgeFAQ(id: number) { + return request(`/api/dashboard/knowledge-faq/${id}`) +} + +export function createKnowledgeFAQ(payload: CreateKnowledgeFAQPayload) { + return request("/api/dashboard/knowledge-faq/create", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function updateKnowledgeFAQ(payload: UpdateKnowledgeFAQPayload) { + return request("/api/dashboard/knowledge-faq/update", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function deleteKnowledgeFAQ(id: number) { + return request("/api/dashboard/knowledge-faq/delete", { + method: "POST", + body: JSON.stringify({ id }), + }) +} + +export function batchMoveKnowledgeFAQs(payload: BatchMoveKnowledgeContentPayload) { + return request("/api/dashboard/knowledge-faq/batch_move", { + method: "POST", + body: JSON.stringify(payload), + }) +} + +export function batchDeleteKnowledgeFAQs(ids: number[]) { + return request("/api/dashboard/knowledge-faq/batch_delete", { + method: "POST", + body: JSON.stringify({ ids }), + }) +} + +function downloadBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = filename + link.click() + URL.revokeObjectURL(url) +} + +export async function downloadKnowledgeFAQImportTemplate() { + const result = await requestBlob("/api/dashboard/knowledge-faq/import_template") + downloadBlob(result.blob, result.filename || "knowledge-faq-import-template.xlsx") +} + +export async function exportKnowledgeFAQs(knowledgeBaseId: number) { + const result = await requestBlob( + `/api/dashboard/knowledge-faq/export${toQueryString({ knowledgeBaseId })}` + ) + downloadBlob(result.blob, result.filename || `knowledge-faq-${knowledgeBaseId}.xlsx`) +} + +export function importKnowledgeFAQs(payload: { + knowledgeBaseId: number + mode: KnowledgeFAQImportMode + file: File +}) { + const formData = new FormData() + formData.set("knowledgeBaseId", String(payload.knowledgeBaseId)) + formData.set("mode", payload.mode) + formData.set("file", payload.file) + return request("/api/dashboard/knowledge-faq/import", { + method: "POST", + body: formData, + }) +} + +export function buildKnowledgeFAQIndex(faqId: number) { + return request