Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into ji
# Conflicts: # frontend-web/src/pages/WorkspaceRoutes.tsx # offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py
This commit is contained in:
commit
aeba15eb16
88 files changed
+13824
-3572
No files matched your search
@@ -31,23 +31,25 @@ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink";
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -37,27 +37,27 @@ const buttonVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
{...(variant !== undefined && { "data-variant": variant })}
|
||||
{...(size !== undefined && { "data-size": size })}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
const Button = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ className, variant = "default", size = "default", asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="button"
|
||||
{...(variant !== undefined && { "data-variant": variant })}
|
||||
{...(size !== undefined && { "data-size": size })}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -10,28 +11,37 @@ function Collapsible({
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
className={cn("cursor-pointer", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const CollapsibleTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof CollapsiblePrimitive.CollapsibleTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleTrigger>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
ref={ref}
|
||||
data-slot="collapsible-trigger"
|
||||
className={cn("cursor-pointer", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
CollapsibleTrigger.displayName =
|
||||
CollapsiblePrimitive.CollapsibleTrigger.displayName;
|
||||
|
||||
const CollapsibleContent = React.forwardRef<
|
||||
React.ElementRef<typeof CollapsiblePrimitive.CollapsibleContent>,
|
||||
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleContent>
|
||||
>(({ ...props }, ref) => {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
ref={ref}
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
CollapsibleContent.displayName =
|
||||
CollapsiblePrimitive.CollapsibleContent.displayName;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
@@ -12,11 +12,20 @@ function Dialog({
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
const DialogTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Trigger>
|
||||
>(({ ...props }, ref) => {
|
||||
return (
|
||||
<DialogPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="dialog-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DialogTrigger.displayName = DialogPrimitive.Trigger.displayName;
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
@@ -24,61 +33,68 @@ function DialogPortal({
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
const DialogClose = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close>
|
||||
>(({ ...props }, ref) => {
|
||||
return <DialogPrimitive.Close ref={ref} data-slot="dialog-close" {...props} />;
|
||||
});
|
||||
|
||||
DialogClose.displayName = DialogPrimitive.Close.displayName;
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
>(({ className, children, showCloseButton = true, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
|
||||
@@ -20,36 +20,42 @@ function DropdownMenuPortal({
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const DropdownMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
|
||||
>(({ ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
@@ -59,28 +65,29 @@ function DropdownMenuGroup({
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}
|
||||
>(({ className, inset, variant = "default", ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
@@ -198,45 +205,51 @@ function DropdownMenuSub({
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
|
||||
@@ -11,34 +11,42 @@ function HoverCard({
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
|
||||
}
|
||||
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
const HoverCardTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Trigger>
|
||||
>(({ ...props }, ref) => {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="hover-card-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
HoverCardTrigger.displayName = HoverCardPrimitive.Trigger.displayName;
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||
@@ -24,68 +24,69 @@ function SelectValue({
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}
|
||||
>(({ className, size = "default", children, ...props }, ref) => {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "item-aligned", align = "center", ...props }, ref) => {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
@@ -100,32 +101,34 @@ function SelectLabel({
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
});
|
||||
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
|
||||
@@ -10,17 +10,23 @@ function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
const SheetTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Trigger>
|
||||
>(({ ...props }, ref) => {
|
||||
return <SheetPrimitive.Trigger ref={ref} data-slot="sheet-trigger" {...props} />;
|
||||
});
|
||||
|
||||
SheetTrigger.displayName = SheetPrimitive.Trigger.displayName;
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
const SheetClose = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Close>
|
||||
>(({ ...props }, ref) => {
|
||||
return <SheetPrimitive.Close ref={ref} data-slot="sheet-close" {...props} />;
|
||||
});
|
||||
|
||||
SheetClose.displayName = SheetPrimitive.Close.displayName;
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
@@ -28,58 +34,62 @@ function SheetPortal({
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
ref={ref}
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}
|
||||
>(({ className, children, side = "right", ...props }, ref) => {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
});
|
||||
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
|
||||
@@ -397,49 +397,53 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
const SidebarGroupLabel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarGroupLabel.displayName = "SidebarGroupLabel";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const SidebarGroupAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarGroupAction.displayName = "SidebarGroupAction";
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
@@ -499,87 +503,99 @@ const sidebarMenuButtonVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
const SidebarMenuButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||
>(
|
||||
(
|
||||
{
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SidebarMenuButton.displayName = "SidebarMenuButton";
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const SidebarMenuAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}
|
||||
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarMenuAction.displayName = "SidebarMenuAction";
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
@@ -670,37 +686,37 @@ function SidebarMenuSubItem({
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const SidebarMenuSubButton = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}
|
||||
>(({ asChild = false, size = "md", isActive = false, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
|
||||
@@ -40,52 +40,60 @@ const tabsListVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>
|
||||
>(({ className, variant = "default", ...props }, ref) => {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
|
||||
@@ -28,33 +28,43 @@ function Tooltip({
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset ?? 4}
|
||||
className={cn(
|
||||
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-foreground text-background dark:text-foreground z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md border px-3 py-1.5 text-xs text-balance shadow-xs dark:border-white/18 dark:bg-[#050504]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
const TooltipTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger>
|
||||
>(({ ...props }, ref) => {
|
||||
return (
|
||||
<TooltipPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="tooltip-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TooltipTrigger.displayName = TooltipPrimitive.Trigger.displayName;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset, children, ...props }, ref) => {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset ?? 4}
|
||||
className={cn(
|
||||
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-foreground text-background dark:text-foreground z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md border px-3 py-1.5 text-xs text-balance shadow-xs dark:border-white/18 dark:bg-[#050504]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsSection } from "@/components/workspace/settings/settings-section";
|
||||
import { useAllUsers } from "@/core/auth/hooks";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
export function UserManagementPage() {
|
||||
const { t } = useI18n();
|
||||
const { data: users, isLoading, error } = useAllUsers(true);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<SettingsSection
|
||||
title={t.adminUsers.title}
|
||||
description={t.adminUsers.description}
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>{t.common.loading}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-destructive text-sm py-4">
|
||||
{String(error)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && users && users.length === 0 && (
|
||||
<div className="text-muted-foreground text-sm py-8 text-center">
|
||||
{t.adminUsers.empty}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && users && users.length > 0 && (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.email}</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.role}</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.skillCount}</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.agentCount}</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.userId}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user, i) => (
|
||||
<tr
|
||||
key={user.id}
|
||||
className={i % 2 === 0 ? "bg-background" : "bg-muted/20"}
|
||||
>
|
||||
<td className="px-4 py-3">{user.email}</td>
|
||||
<td className="px-4 py-3">
|
||||
{user.system_role === "admin" ? (
|
||||
<Badge variant="default">{t.adminUsers.roleAdmin}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">{t.adminUsers.roleUser}</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{user.skill_count}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{user.agent_count}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground truncate max-w-[160px]">
|
||||
{user.id}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
EyeIcon,
|
||||
LoaderIcon,
|
||||
PackageIcon,
|
||||
PencilIcon,
|
||||
SaveIcon,
|
||||
SquareArrowOutUpRightIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
@@ -32,6 +34,8 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { CodeEditor } from "@/components/workspace/code-editor";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api";
|
||||
import {
|
||||
downloadArtifactFile,
|
||||
@@ -39,6 +43,7 @@ import {
|
||||
} from "@/core/artifacts/download";
|
||||
import { useArtifactContent } from "@/core/artifacts/hooks";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { installSkill } from "@/core/skills/api";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
@@ -119,8 +124,27 @@ export function ArtifactFileDetail({
|
||||
enabled: isCodeFile && !isWriteFile,
|
||||
});
|
||||
|
||||
const displayContent = content ?? "";
|
||||
const queryClient = useQueryClient();
|
||||
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
|
||||
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
|
||||
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
|
||||
const [showResumeAutoScroll, setShowResumeAutoScroll] = useState(false);
|
||||
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(null);
|
||||
const [editedContent, setEditedContent] = useState<string | null>(null);
|
||||
// Tracks last manually-saved content for write-file: artifacts (whose displayContent
|
||||
// always reflects the original tool-call output, not the on-disk file after save).
|
||||
const [savedContent, setSavedContent] = useState<string | null>(null);
|
||||
const artifactContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const resumeButtonTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const displayContent = (isWriteFile && savedContent !== null ? savedContent : null) ?? content ?? "";
|
||||
const isHtmlStreaming = language === "html" && thread.isLoading;
|
||||
const canEdit = isCodeFile && !isSkillFile && !thread.isLoading;
|
||||
const activeContent = editedContent ?? displayContent;
|
||||
const isDirty = editedContent !== null && editedContent !== displayContent;
|
||||
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
||||
const fileTypeLabel = useMemo(() => {
|
||||
if (language === "html") return "HTML";
|
||||
@@ -130,22 +154,12 @@ export function ArtifactFileDetail({
|
||||
return "FILE";
|
||||
}, [isSkillFile, language]);
|
||||
|
||||
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
|
||||
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [showPreviewSwitchPulse, setShowPreviewSwitchPulse] = useState(false);
|
||||
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
|
||||
const [showResumeAutoScroll, setShowResumeAutoScroll] = useState(false);
|
||||
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const artifactContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const resumeButtonTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setAllowAutoViewSwitch(true);
|
||||
setAutoScrollEnabled(true);
|
||||
setShowResumeAutoScroll(false);
|
||||
setEditedContent(null);
|
||||
setSavedContent(null);
|
||||
|
||||
if (language === "html") {
|
||||
setViewMode(thread.isLoading ? "code" : "preview");
|
||||
@@ -308,6 +322,40 @@ export function ArtifactFileDetail({
|
||||
}, 1600);
|
||||
}, [scrollArtifactToBottom]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!isDirty || isSaving) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const pathWithoutLeadingSlash = filepath.replace(/^\//, "");
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/threads/${threadId}/artifacts/${pathWithoutLeadingSlash}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: activeContent }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail ?? "保存失败");
|
||||
}
|
||||
if (isWriteFile) {
|
||||
setSavedContent(activeContent);
|
||||
} else {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["artifact", filepathFromProps, threadId],
|
||||
});
|
||||
}
|
||||
setEditedContent(null);
|
||||
toast.success("已保存");
|
||||
} catch (error) {
|
||||
console.error("Save artifact failed:", error);
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [isDirty, isSaving, filepath, threadId, activeContent, queryClient, filepathFromProps, isWriteFile]);
|
||||
|
||||
const handleInstallSkill = useCallback(async () => {
|
||||
if (isInstalling) return;
|
||||
|
||||
@@ -406,7 +454,27 @@ export function ArtifactFileDetail({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{canEdit && viewMode === "code" && !isDirty && (
|
||||
<span className="text-muted-foreground bg-muted/65 inline-flex items-center gap-1.5 rounded-full border border-black/6 px-2.5 py-1 text-[11px] dark:border-white/8">
|
||||
<PencilIcon className="size-3" />
|
||||
可编辑
|
||||
</span>
|
||||
)}
|
||||
{canEdit && viewMode === "code" && isDirty && (
|
||||
<span className="text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950/40 inline-flex items-center rounded-full border border-amber-200 dark:border-amber-800 px-2.5 py-1 text-[11px]">
|
||||
未保存
|
||||
</span>
|
||||
)}
|
||||
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
|
||||
{canEdit && viewMode === "code" && (
|
||||
<ArtifactAction
|
||||
icon={isSaving ? LoaderIcon : SaveIcon}
|
||||
label="保存"
|
||||
tooltip={isDirty ? "保存修改 (Ctrl+S)" : "无修改"}
|
||||
disabled={isSaving || !isDirty}
|
||||
onClick={handleSave}
|
||||
/>
|
||||
)}
|
||||
{!isWriteFile && filepath.endsWith(".skill") && (
|
||||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
||||
<ArtifactAction
|
||||
@@ -513,17 +581,26 @@ export function ArtifactFileDetail({
|
||||
viewMode === "preview" &&
|
||||
(language === "markdown" || language === "html") && (
|
||||
<ArtifactFilePreview
|
||||
content={displayContent}
|
||||
content={activeContent}
|
||||
language={language ?? "text"}
|
||||
animated={showPreviewSwitchPulse}
|
||||
/>
|
||||
)}
|
||||
{isCodeFile && viewMode === "code" && (
|
||||
<div className="size-full">
|
||||
<div
|
||||
className="size-full"
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none bg-transparent"
|
||||
value={displayContent ?? ""}
|
||||
readonly
|
||||
value={activeContent}
|
||||
readonly={!canEdit}
|
||||
onChange={canEdit ? (v) => setEditedContent(v) : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -41,6 +41,7 @@ export function CodeEditor({
|
||||
readonly,
|
||||
disabled,
|
||||
autoFocus,
|
||||
onChange,
|
||||
settings,
|
||||
}: {
|
||||
className?: string;
|
||||
@@ -49,6 +50,7 @@ export function CodeEditor({
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
settings?: unknown;
|
||||
}) {
|
||||
const {
|
||||
@@ -107,6 +109,7 @@ export function CodeEditor({
|
||||
}}
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { BellIcon, CheckCheckIcon, Loader2, Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
SidebarMenuButton,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
useDeleteNotification,
|
||||
useMarkAllNotificationsRead,
|
||||
useMarkNotificationRead,
|
||||
useNotifications,
|
||||
useUnreadCount,
|
||||
} from "@/core/notifications";
|
||||
import type { NotificationItem } from "@/core/notifications";
|
||||
import { formatTimeAgo } from "@/core/utils/datetime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const BELL_LIMIT = 20;
|
||||
|
||||
export function NotificationBell() {
|
||||
const { t } = useI18n();
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Render badge from the lightweight count endpoint so the bell stays cheap
|
||||
// when nobody opens it. Only when the user actually opens the dropdown do
|
||||
// we pay for the full list query.
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
const {
|
||||
data: notifications,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useNotifications({ limit: BELL_LIMIT });
|
||||
const markRead = useMarkNotificationRead();
|
||||
const markAllRead = useMarkAllNotificationsRead();
|
||||
const removeNotification = useDeleteNotification();
|
||||
|
||||
const hasUnread = unreadCount > 0;
|
||||
const items = notifications ?? [];
|
||||
|
||||
async function handleClickItem(notification: NotificationItem) {
|
||||
if (notification.read) return;
|
||||
try {
|
||||
await markRead.mutateAsync(notification.id);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarkAllRead() {
|
||||
if (!hasUnread) return;
|
||||
try {
|
||||
await markAllRead.mutateAsync();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(notification: NotificationItem) {
|
||||
try {
|
||||
await removeNotification.mutateAsync(notification.id);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
aria-label={t.notifications.bellAria}
|
||||
>
|
||||
{isSidebarOpen ? (
|
||||
<div className="text-sidebar-foreground/85 flex w-full items-center gap-2 text-left text-sm">
|
||||
<div className="relative">
|
||||
<BellIcon className="size-4" />
|
||||
{hasUnread && (
|
||||
<span className="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span>{t.notifications.title}</span>
|
||||
{hasUnread && (
|
||||
<span className="text-muted-foreground ml-auto text-xs">
|
||||
{t.notifications.unreadShort.replace("{count}", String(unreadCount))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center">
|
||||
<div className="relative">
|
||||
<BellIcon className="text-sidebar-foreground/80 size-4" />
|
||||
{hasUnread && (
|
||||
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-red-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
className="w-96 p-0"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BellIcon className="size-4" />
|
||||
<span className="text-sm font-semibold">
|
||||
{t.notifications.title}
|
||||
</span>
|
||||
{isFetching && !isLoading && (
|
||||
<Loader2 className="text-muted-foreground size-3 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={!hasUnread || markAllRead.isPending}
|
||||
onClick={() => void handleMarkAllRead()}
|
||||
>
|
||||
<CheckCheckIcon className="mr-1 size-3.5" />
|
||||
{t.notifications.markAllRead}
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-muted-foreground py-10 text-center text-sm">
|
||||
{t.notifications.empty}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{items.map((notification) => (
|
||||
<li
|
||||
key={notification.id}
|
||||
className={cn(
|
||||
"group cursor-pointer px-3 py-2 transition-colors hover:bg-accent/40",
|
||||
!notification.read && "bg-blue-500/5",
|
||||
)}
|
||||
onClick={() => void handleClickItem(notification)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{!notification.read && (
|
||||
<span className="mt-1.5 size-2 shrink-0 rounded-full bg-blue-500" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p
|
||||
className={cn(
|
||||
"truncate text-sm",
|
||||
!notification.read && "font-medium",
|
||||
)}
|
||||
title={notification.title}
|
||||
>
|
||||
{notification.title || notification.type}
|
||||
</p>
|
||||
{notification.created_at && (
|
||||
<span className="text-muted-foreground shrink-0 text-[10px]">
|
||||
{formatTimeAgo(notification.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{notification.body && (
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs">
|
||||
{notification.body}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-destructive size-6 shrink-0 opacity-0 group-hover:opacity-100"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDelete(notification);
|
||||
}}
|
||||
aria-label={t.notifications.deleteAria}
|
||||
>
|
||||
<Trash2Icon className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -173,91 +173,89 @@ export function RecentChatList() {
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{visibleThreads.map((thread) => {
|
||||
const isActive = pathOfThread(thread) === pathname;
|
||||
return (
|
||||
<SidebarMenuItem
|
||||
key={thread.thread_id}
|
||||
className="group/side-menu-item"
|
||||
>
|
||||
<SidebarMenuButton isActive={isActive} asChild>
|
||||
<div>
|
||||
<Link
|
||||
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||
href={pathOfThread(thread)}
|
||||
>
|
||||
{titleOfThread(thread)}
|
||||
</Link>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction
|
||||
showOnHover
|
||||
className="bg-background/50 hover:bg-background"
|
||||
>
|
||||
<MoreHorizontal />
|
||||
<span className="sr-only">{t.common.more}</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-48 rounded-lg"
|
||||
side={"right"}
|
||||
align={"start"}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
handleRenameClick(
|
||||
thread.thread_id,
|
||||
titleOfThread(thread),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Pencil className="text-muted-foreground" />
|
||||
<span>{t.common.rename}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleShare(thread)}
|
||||
>
|
||||
<Share2 className="text-muted-foreground" />
|
||||
<span>{t.common.share}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Download className="text-muted-foreground" />
|
||||
<span>{t.common.export}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
handleExport(thread, "markdown")
|
||||
}
|
||||
>
|
||||
<FileText className="text-muted-foreground" />
|
||||
<span>{t.common.exportAsMarkdown}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
handleExport(thread, "json")
|
||||
}
|
||||
>
|
||||
<FileJson className="text-muted-foreground" />
|
||||
<span>{t.common.exportAsJSON}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleDelete(thread.thread_id)}
|
||||
>
|
||||
<Trash2 className="text-muted-foreground" />
|
||||
<span>{t.common.delete}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<SidebarMenuItem
|
||||
key={thread.thread_id}
|
||||
className="group/side-menu-item"
|
||||
>
|
||||
<SidebarMenuButton isActive={isActive} asChild>
|
||||
<Link
|
||||
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||
href={pathOfThread(thread)}
|
||||
>
|
||||
{titleOfThread(thread)}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction
|
||||
showOnHover
|
||||
className="bg-background/50 hover:bg-background"
|
||||
>
|
||||
<MoreHorizontal />
|
||||
<span className="sr-only">{t.common.more}</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-48 rounded-lg"
|
||||
side={"right"}
|
||||
align={"start"}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
handleRenameClick(
|
||||
thread.thread_id,
|
||||
titleOfThread(thread),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Pencil className="text-muted-foreground" />
|
||||
<span>{t.common.rename}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleShare(thread)}
|
||||
>
|
||||
<Share2 className="text-muted-foreground" />
|
||||
<span>{t.common.share}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Download className="text-muted-foreground" />
|
||||
<span>{t.common.export}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
handleExport(thread, "markdown")
|
||||
}
|
||||
>
|
||||
<FileText className="text-muted-foreground" />
|
||||
<span>{t.common.exportAsMarkdown}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
handleExport(thread, "json")
|
||||
}
|
||||
>
|
||||
<FileJson className="text-muted-foreground" />
|
||||
<span>{t.common.exportAsJSON}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleDelete(thread.thread_id)}
|
||||
>
|
||||
<Trash2 className="text-muted-foreground" />
|
||||
<span>{t.common.delete}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import {
|
||||
BellIcon,
|
||||
InfoIcon,
|
||||
BrainIcon,
|
||||
PaletteIcon,
|
||||
SparklesIcon,
|
||||
@@ -136,12 +135,8 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
{activeSection === "appearance" && <AppearanceSettingsPage />}
|
||||
{activeSection === "memory" && <MemorySettingsPage />}
|
||||
{activeSection === "tools" && <ToolSettingsPage />}
|
||||
{activeSection === "skills" && (
|
||||
<SkillSettingsPage
|
||||
onClose={() => props.onOpenChange?.(false)}
|
||||
/>
|
||||
)}
|
||||
{activeSection === "notification" && <NotificationSettingsPage />}
|
||||
{activeSection === "skills" && <SkillSettingsPage />}
|
||||
{activeSection === "about" && <AboutSettingsPage />}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, CheckCircle2, FileArchive, Loader2, SparklesIcon, Upload } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArchiveIcon,
|
||||
CheckCircle2,
|
||||
ClockIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
FileArchive,
|
||||
Loader2,
|
||||
PinIcon,
|
||||
PinOffIcon,
|
||||
SparklesIcon,
|
||||
Trash2Icon,
|
||||
TrendingUpIcon,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -10,6 +25,7 @@ import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
@@ -29,13 +45,15 @@ import {
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
} from "@/components/ui/item";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
||||
import {
|
||||
useEnableSkill,
|
||||
useDeleteCustomSkill,
|
||||
useInstallSkillUpload,
|
||||
useSetSkillPinned,
|
||||
useSetSkillPublished,
|
||||
useSkills,
|
||||
useValidateSkillUpload,
|
||||
} from "@/core/skills/hooks";
|
||||
@@ -44,6 +62,7 @@ import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
import { SkillStateSheet } from "./skill-state-sheet";
|
||||
|
||||
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
||||
const { t } = useI18n();
|
||||
@@ -73,29 +92,110 @@ function SkillSettingsList({
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const [filter, setFilter] = useState<string>("public");
|
||||
const [filter, setFilter] = useState<string>("all");
|
||||
const [installOpen, setInstallOpen] = useState(false);
|
||||
const { mutate: enableSkill } = useEnableSkill();
|
||||
const filteredSkills = useMemo(
|
||||
() => skills.filter((skill) => skill.category === filter),
|
||||
[skills, filter],
|
||||
);
|
||||
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
|
||||
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
const isAdmin = storedAuth?.system_role === "admin";
|
||||
|
||||
// Visible-to-me skills, classified into the three buckets the UI cares about.
|
||||
// "All" = built-in + anyone-published-custom + mine (active state only).
|
||||
// "Mine" = strictly my own custom skills (active state only).
|
||||
const { activeAll, activeMine, staleVisible, archivedVisible } = useMemo(() => {
|
||||
const all: Skill[] = [];
|
||||
const mine: Skill[] = [];
|
||||
const stale: Skill[] = [];
|
||||
const archived: Skill[] = [];
|
||||
for (const skill of skills) {
|
||||
const state = skill.state ?? "active";
|
||||
const isCustom = skill.category === "custom";
|
||||
const isOwnedByMe =
|
||||
isCustom && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
|
||||
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
|
||||
const isPublishedByOthers =
|
||||
isCustom && !isOwnedByMe && !isBuiltin && !!skill.published;
|
||||
|
||||
if (state === "stale") {
|
||||
stale.push(skill);
|
||||
continue;
|
||||
}
|
||||
if (state === "archived") {
|
||||
archived.push(skill);
|
||||
continue;
|
||||
}
|
||||
// state === active
|
||||
if (!isCustom || isBuiltin || isOwnedByMe || isPublishedByOthers) {
|
||||
all.push(skill);
|
||||
}
|
||||
if (isOwnedByMe) {
|
||||
mine.push(skill);
|
||||
}
|
||||
}
|
||||
return {
|
||||
activeAll: all,
|
||||
activeMine: mine,
|
||||
staleVisible: stale,
|
||||
archivedVisible: archived,
|
||||
};
|
||||
}, [skills, currentUserId]);
|
||||
|
||||
const filteredSkills = filter === "mine" ? activeMine : activeAll;
|
||||
const staleCount = staleVisible.length;
|
||||
const archivedCount = archivedVisible.length;
|
||||
|
||||
const handleCreateSkill = () => {
|
||||
onClose?.();
|
||||
router.push("/?mode=skill");
|
||||
};
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<header className="flex justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Tabs defaultValue="public" onValueChange={setFilter}>
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tabs value={filter} onValueChange={setFilter}>
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="public">{t.common.public}</TabsTrigger>
|
||||
<TabsTrigger value="custom">{t.common.custom}</TabsTrigger>
|
||||
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
|
||||
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={staleCount === 0}
|
||||
onClick={() => setStaleSheetOpen(true)}
|
||||
title={t.settings.skills.badgeStaleTitle}
|
||||
>
|
||||
<ClockIcon className="size-4" />
|
||||
{t.settings.skills.entryStale.replace("{count}", String(staleCount))}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={archivedCount === 0}
|
||||
onClick={() => setArchiveSheetOpen(true)}
|
||||
title={t.settings.skills.badgeArchivedTitle}
|
||||
>
|
||||
<ArchiveIcon className="size-4" />
|
||||
{t.settings.skills.entryArchived.replace(
|
||||
"{count}",
|
||||
String(archivedCount),
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onClose?.();
|
||||
router.push("/page/workspace/skills/evolution");
|
||||
}}
|
||||
>
|
||||
<TrendingUpIcon className="size-4" />
|
||||
{t.settings.skills.goToEvolution}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -116,34 +216,267 @@ function SkillSettingsList({
|
||||
)}
|
||||
{filteredSkills.length > 0 &&
|
||||
filteredSkills.map((skill) => (
|
||||
<Item className="w-full" variant="outline" key={skill.name}>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<div className="flex items-center gap-2">{skill.name}</div>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="line-clamp-4">
|
||||
{skill.description}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={skill.enabled}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
onCheckedChange={(checked) =>
|
||||
enableSkill({ skillName: skill.name, enabled: checked })
|
||||
}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
<SkillRow
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
))}
|
||||
<InstallSkillDialog
|
||||
open={installOpen}
|
||||
onClose={() => setInstallOpen(false)}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
<SkillStateSheet
|
||||
kind="stale"
|
||||
open={staleSheetOpen}
|
||||
onOpenChange={setStaleSheetOpen}
|
||||
skills={staleVisible}
|
||||
/>
|
||||
<SkillStateSheet
|
||||
kind="archived"
|
||||
open={archiveSheetOpen}
|
||||
onOpenChange={setArchiveSheetOpen}
|
||||
skills={archivedVisible}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillRow({
|
||||
skill,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
}: {
|
||||
skill: Skill;
|
||||
currentUserId: string | null;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const setPublished = useSetSkillPublished();
|
||||
const setPinned = useSetSkillPinned();
|
||||
const deleteSkill = useDeleteCustomSkill();
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
|
||||
const isCustom = skill.category === "custom";
|
||||
// Built-in flag falls back to "no DB owner" so legacy responses without
|
||||
// the multitenancy fields still classify public skills correctly.
|
||||
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
|
||||
const isOwned =
|
||||
isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
|
||||
const isFromOthers = isCustom && !isBuiltin && !isOwned;
|
||||
// Admin can edit/delete/unpublish anyone's skill; non-admin only their own.
|
||||
const canManage = isCustom && (isOwned || isAdmin);
|
||||
const canTogglePublish = canManage && !isBuiltin;
|
||||
|
||||
async function handleTogglePublished(next: boolean) {
|
||||
try {
|
||||
await setPublished.mutateAsync({ skillName: skill.name, published: next });
|
||||
toast.success(
|
||||
next
|
||||
? t.settings.skills.publishSuccess
|
||||
: t.settings.skills.unpublishSuccess,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await deleteSkill.mutateAsync(skill.name);
|
||||
toast.success(t.settings.skills.deleteSuccess);
|
||||
setConfirmDeleteOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTogglePinned() {
|
||||
const next = !skill.pinned;
|
||||
try {
|
||||
await setPinned.mutateAsync({ skillName: skill.name, pinned: next });
|
||||
toast.success(
|
||||
next ? t.settings.skills.pinSuccess : t.settings.skills.unpinSuccess,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Item className="w-full" variant="outline">
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>{skill.name}</span>
|
||||
{isBuiltin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t.settings.skills.badgeBuiltin}
|
||||
</Badge>
|
||||
)}
|
||||
{isOwned && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
|
||||
>
|
||||
{t.settings.skills.badgeMine}
|
||||
</Badge>
|
||||
)}
|
||||
{isFromOthers && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
||||
title={
|
||||
skill.owner_user_id
|
||||
? t.settings.skills.badgeFromOthersTitle.replace(
|
||||
"{user_id}",
|
||||
skill.owner_user_id,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t.settings.skills.badgeFromOthers}
|
||||
</Badge>
|
||||
)}
|
||||
{isOwned && skill.published && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||
title={t.settings.skills.badgePublishedTitle}
|
||||
>
|
||||
{t.settings.skills.badgePublished}
|
||||
</Badge>
|
||||
)}
|
||||
{isCustom && !isBuiltin && skill.source === "agent" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
|
||||
title={t.settings.skills.badgeSourceAgentTitle}
|
||||
>
|
||||
{t.settings.skills.badgeSourceAgent}
|
||||
</Badge>
|
||||
)}
|
||||
{isCustom && !isBuiltin && skill.source === "upload" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
|
||||
title={t.settings.skills.badgeSourceUploadTitle}
|
||||
>
|
||||
{t.settings.skills.badgeSourceUpload}
|
||||
</Badge>
|
||||
)}
|
||||
{skill.pinned && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
|
||||
title={t.settings.skills.badgePinnedTitle}
|
||||
>
|
||||
{t.settings.skills.badgePinned}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="line-clamp-4">
|
||||
{skill.description}
|
||||
</ItemDescription>
|
||||
{skill.use_count !== undefined && (
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
{skill.use_count > 0
|
||||
? t.settings.skills.useCountInline.replace(
|
||||
"{count}",
|
||||
String(skill.use_count),
|
||||
)
|
||||
: t.settings.skills.useCountInlineZero}
|
||||
</div>
|
||||
)}
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
disabled={setPinned.isPending}
|
||||
onClick={() => void handleTogglePinned()}
|
||||
title={
|
||||
skill.pinned
|
||||
? t.settings.skills.unpinAction
|
||||
: t.settings.skills.pinAction
|
||||
}
|
||||
>
|
||||
{skill.pinned ? (
|
||||
<PinOffIcon className="size-3.5" />
|
||||
) : (
|
||||
<PinIcon className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{canTogglePublish && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
disabled={setPublished.isPending}
|
||||
onClick={() => void handleTogglePublished(!skill.published)}
|
||||
title={
|
||||
skill.published
|
||||
? t.settings.skills.unpublishAction
|
||||
: t.settings.skills.publishAction
|
||||
}
|
||||
>
|
||||
{skill.published ? (
|
||||
<EyeOffIcon className="size-3.5" />
|
||||
) : (
|
||||
<EyeIcon className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||
onClick={() => setConfirmDeleteOpen(true)}
|
||||
title={t.settings.skills.deleteAction}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.settings.skills.deleteAction}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t.settings.skills.deleteConfirm.replace("{name}", skill.name)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConfirmDeleteOpen(false)}
|
||||
disabled={deleteSkill.isPending}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={deleteSkill.isPending}
|
||||
>
|
||||
{deleteSkill.isPending ? t.common.loading : t.common.delete}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
@@ -167,9 +500,11 @@ function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
|
||||
function InstallSkillDialog({
|
||||
open,
|
||||
onClose,
|
||||
isAdmin,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const dialog = t.settings.skills.installDialog;
|
||||
@@ -178,6 +513,7 @@ function InstallSkillDialog({
|
||||
const [validation, setValidation] = useState<ValidateSkillUploadResponse | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [targetCategory, setTargetCategory] = useState<"custom" | "public">("custom");
|
||||
const validateMutation = useValidateSkillUpload();
|
||||
const installMutation = useInstallSkillUpload();
|
||||
|
||||
@@ -186,6 +522,7 @@ function InstallSkillDialog({
|
||||
setValidation(null);
|
||||
setValidationError(null);
|
||||
setIsDragging(false);
|
||||
setTargetCategory("custom");
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}, []);
|
||||
|
||||
@@ -242,7 +579,7 @@ function InstallSkillDialog({
|
||||
async (overwrite: boolean) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
const result = await installMutation.mutateAsync({ file, overwrite });
|
||||
const result = await installMutation.mutateAsync({ file, overwrite, targetCategory });
|
||||
toast.success(
|
||||
(result.overwritten ? dialog.installSuccessReplaced : dialog.installSuccess).replace(
|
||||
"{name}",
|
||||
@@ -255,13 +592,15 @@ function InstallSkillDialog({
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
},
|
||||
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, onClose, reset],
|
||||
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, onClose, reset, targetCategory],
|
||||
);
|
||||
|
||||
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
|
||||
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
|
||||
const canInstall = !!validation && !validationError && !isPublicConflict && !isCustomConflict;
|
||||
const canOverwrite = !!validation && !validationError && isCustomConflict;
|
||||
// Admin targeting public directory can overwrite an existing public skill.
|
||||
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
|
||||
const canInstall = !!validation && !validationError && !isCustomConflict && (adminCanOverwritePublic ? false : !isPublicConflict);
|
||||
const canOverwrite = !!validation && !validationError && (!!isCustomConflict || adminCanOverwritePublic);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||
@@ -309,6 +648,27 @@ function InstallSkillDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-muted-foreground shrink-0">{dialog.targetCategoryLabel}</span>
|
||||
<div className="flex gap-4">
|
||||
{(["custom", "public"] as const).map((cat) => (
|
||||
<label key={cat} className="flex cursor-pointer items-center gap-1.5">
|
||||
<input
|
||||
type="radio"
|
||||
name="targetCategory"
|
||||
value={cat}
|
||||
checked={targetCategory === cat}
|
||||
onChange={() => setTargetCategory(cat)}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<span>{cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validateMutation.isPending && (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
@@ -366,10 +726,10 @@ function InstallSkillDialog({
|
||||
</div>
|
||||
)}
|
||||
{isPublicConflict && (
|
||||
<div className="text-destructive mt-1 flex items-start gap-2 text-xs">
|
||||
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{dialog.conflictPublic.replace("{name}", validation.skill_name)}
|
||||
{(adminCanOverwritePublic ? dialog.conflictPublicAdminHint : dialog.conflictPublic).replace("{name}", validation.skill_name)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
"use client";
|
||||
|
||||
import { ArchiveIcon, ClockIcon, MoreHorizontalIcon, RotateCcwIcon, Trash2Icon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useDeleteCustomSkill, useSetSkillState } from "@/core/skills/hooks";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Kind = "stale" | "archived";
|
||||
|
||||
export function SkillStateSheet({
|
||||
kind,
|
||||
open,
|
||||
onOpenChange,
|
||||
skills,
|
||||
}: {
|
||||
kind: Kind;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
skills: Skill[];
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const tt = t.settings.skills;
|
||||
const ts = tt.stateSheet;
|
||||
const isArchived = kind === "archived";
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-2xl">
|
||||
<SheetHeader className="border-b">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
{isArchived ? (
|
||||
<ArchiveIcon className="size-4" />
|
||||
) : (
|
||||
<ClockIcon className="size-4" />
|
||||
)}
|
||||
{isArchived ? ts.archivedTitle : ts.staleTitle}
|
||||
<span className="text-muted-foreground text-sm font-normal">
|
||||
({skills.length})
|
||||
</span>
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{isArchived ? ts.archivedDescription : ts.staleDescription}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{skills.length === 0 ? (
|
||||
<div className="text-muted-foreground py-12 text-center text-sm">
|
||||
{ts.empty}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{skills.map((skill) => (
|
||||
<SkillStateRow key={skill.name} skill={skill} kind={kind} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillStateRow({ skill, kind }: { skill: Skill; kind: Kind }) {
|
||||
const { t } = useI18n();
|
||||
const tt = t.settings.skills;
|
||||
const ts = tt.stateSheet;
|
||||
const setState = useSetSkillState();
|
||||
const deleteSkill = useDeleteCustomSkill();
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [viewOpen, setViewOpen] = useState(false);
|
||||
|
||||
const pending = setState.isPending || deleteSkill.isPending;
|
||||
const isArchived = kind === "archived";
|
||||
|
||||
const timeLabel = useMemo(() => {
|
||||
if (isArchived) {
|
||||
const when = skill.last_activity_at;
|
||||
return when
|
||||
? ts.archivedAt.replace("{when}", formatRelative(when))
|
||||
: ts.archivedAt.replace("{when}", "—");
|
||||
}
|
||||
const when = skill.last_activity_at;
|
||||
return when
|
||||
? ts.lastUsedAt.replace("{when}", formatRelative(when))
|
||||
: ts.neverUsed;
|
||||
}, [isArchived, skill.last_activity_at, ts.archivedAt, ts.lastUsedAt, ts.neverUsed]);
|
||||
|
||||
async function handleRestore() {
|
||||
try {
|
||||
await setState.mutateAsync({ skillName: skill.name, state: "active" });
|
||||
toast.success(ts.restoreSuccess);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchive() {
|
||||
try {
|
||||
await setState.mutateAsync({ skillName: skill.name, state: "archived" });
|
||||
toast.success(ts.archiveSuccess);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await deleteSkill.mutateAsync(skill.name);
|
||||
toast.success(tt.deleteSuccess);
|
||||
setConfirmDeleteOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-card flex flex-col gap-2 rounded-md border p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{skill.name}</span>
|
||||
{isArchived ? (
|
||||
<Badge variant="secondary" className="text-xs" title={tt.badgeArchivedTitle}>
|
||||
{tt.badgeArchived}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/30 text-amber-700 dark:text-amber-300 text-xs"
|
||||
title={tt.badgeStaleTitle}
|
||||
>
|
||||
{tt.badgeStale}
|
||||
</Badge>
|
||||
)}
|
||||
{skill.source === "agent" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
|
||||
title={tt.badgeSourceAgentTitle}
|
||||
>
|
||||
{tt.badgeSourceAgent}
|
||||
</Badge>
|
||||
)}
|
||||
{skill.source === "upload" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
|
||||
title={tt.badgeSourceUploadTitle}
|
||||
>
|
||||
{tt.badgeSourceUpload}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">
|
||||
{skill.description}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 text-xs">
|
||||
<span>{timeLabel}</span>
|
||||
<span>
|
||||
{skill.use_count && skill.use_count > 0
|
||||
? tt.useCountInline.replace(
|
||||
"{count}",
|
||||
String(skill.use_count),
|
||||
)
|
||||
: tt.useCountInlineZero}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleRestore()}
|
||||
disabled={pending}
|
||||
>
|
||||
<RotateCcwIcon className="size-3.5" />
|
||||
{ts.actionRestore}
|
||||
</Button>
|
||||
{!isArchived && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleArchive()}
|
||||
disabled={pending}
|
||||
>
|
||||
<ArchiveIcon className="size-3.5" />
|
||||
{ts.actionArchiveNow}
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
disabled={pending}
|
||||
title={ts.moreMenu}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => setViewOpen(true)}>
|
||||
{ts.actionView}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setConfirmDeleteOpen(true)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
{ts.actionDelete}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SkillViewDialog
|
||||
skill={skill}
|
||||
open={viewOpen}
|
||||
onOpenChange={setViewOpen}
|
||||
/>
|
||||
|
||||
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{tt.deleteAction}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{tt.deleteConfirm.replace("{name}", skill.name)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConfirmDeleteOpen(false)}
|
||||
disabled={deleteSkill.isPending}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={deleteSkill.isPending}
|
||||
>
|
||||
{deleteSkill.isPending ? t.common.loading : t.common.delete}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillViewDialog({
|
||||
skill,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
skill: Skill;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const tv = t.settings.skills.viewDialog;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{skill.name}</DialogTitle>
|
||||
<DialogDescription>{tv.title}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 text-sm">
|
||||
<Row label={tv.descriptionLabel}>
|
||||
<span className="whitespace-pre-wrap">{skill.description}</span>
|
||||
</Row>
|
||||
<Row label={tv.stateLabel}>
|
||||
<span className={cn("font-medium", stateColor(skill.state))}>
|
||||
{skill.state ?? "active"}
|
||||
</span>
|
||||
</Row>
|
||||
{skill.use_count !== undefined && (
|
||||
<Row label={tv.useCountLabel}>{skill.use_count}</Row>
|
||||
)}
|
||||
{skill.last_activity_at && (
|
||||
<Row label={tv.lastUsedLabel}>
|
||||
{formatRelative(skill.last_activity_at)}
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{tv.close}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[80px_1fr] gap-2">
|
||||
<span className="text-muted-foreground shrink-0 text-xs">{label}</span>
|
||||
<div className="min-w-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function stateColor(state: string | undefined): string {
|
||||
switch (state) {
|
||||
case "archived":
|
||||
return "text-muted-foreground";
|
||||
case "stale":
|
||||
return "text-amber-600 dark:text-amber-300";
|
||||
default:
|
||||
return "text-emerald-600 dark:text-emerald-300";
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
if (Number.isNaN(ms)) return iso;
|
||||
const sec = Math.max(0, Math.floor(ms / 1000));
|
||||
const min = Math.floor(sec / 60);
|
||||
const hour = Math.floor(min / 60);
|
||||
const day = Math.floor(hour / 24);
|
||||
if (day >= 1) return `${day}d ago`;
|
||||
if (hour >= 1) return `${hour}h ago`;
|
||||
if (min >= 1) return `${min}m ago`;
|
||||
return "just now";
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
Loader2Icon,
|
||||
PlayIcon,
|
||||
RefreshCwIcon,
|
||||
SaveIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useCuratorConfig,
|
||||
useCuratorLogs,
|
||||
useCuratorStatus,
|
||||
useRunCurator,
|
||||
useUpdateCuratorConfig,
|
||||
} from "@/core/curator/hooks";
|
||||
import type { CuratorConfig } from "@/core/curator/types";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function SkillEvolutionPanel() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const tt = t.settings.skillEvolution;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4">
|
||||
<header className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{tt.title}</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{tt.description}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push("/page/workspace/skills")}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
{tt.backToSkills}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<StatusCard />
|
||||
<ConfigCard />
|
||||
<LogsCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusCard() {
|
||||
const { t } = useI18n();
|
||||
const tt = t.settings.skillEvolution.status;
|
||||
const { status, isLoading } = useCuratorStatus();
|
||||
const runCurator = useRunCurator();
|
||||
|
||||
const lastRunDisplay = useMemo(() => {
|
||||
if (!status?.last_run_at) return tt.lastRunNever;
|
||||
try {
|
||||
return new Date(status.last_run_at).toLocaleString();
|
||||
} catch {
|
||||
return status.last_run_at;
|
||||
}
|
||||
}, [status?.last_run_at, tt.lastRunNever]);
|
||||
|
||||
const handleRun = async () => {
|
||||
try {
|
||||
await runCurator.mutateAsync();
|
||||
toast.success(tt.runStarted);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>{tt.title}</CardTitle>
|
||||
{status && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Badge variant={status.enabled ? "default" : "secondary"}>
|
||||
{status.enabled ? tt.enabled : tt.disabled}
|
||||
</Badge>
|
||||
{status.paused && (
|
||||
<Badge variant="outline">{tt.paused}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRun}
|
||||
disabled={runCurator.isPending}
|
||||
>
|
||||
{runCurator.isPending ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<PlayIcon className="size-4" />
|
||||
)}
|
||||
{tt.runNow}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : status ? (
|
||||
<div className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<StatRow label={tt.runCount}>
|
||||
{status.run_count}
|
||||
{tt.runCountUnit && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
{tt.runCountUnit}
|
||||
</span>
|
||||
)}
|
||||
</StatRow>
|
||||
<StatRow label={tt.lastRunAt}>{lastRunDisplay}</StatRow>
|
||||
<StatRow label={tt.lastRunSummary} className="sm:col-span-2">
|
||||
<span className="text-muted-foreground">
|
||||
{status.last_run_summary ?? tt.lastRunSummaryEmpty}
|
||||
</span>
|
||||
</StatRow>
|
||||
<StatRow label={tt.skillCounts} className="sm:col-span-2">
|
||||
<span className="text-muted-foreground inline-flex flex-wrap gap-3">
|
||||
<span>
|
||||
{tt.total}: {status.agent_created_skills.total}
|
||||
</span>
|
||||
<span>
|
||||
{tt.active}: {status.agent_created_skills.active}
|
||||
</span>
|
||||
<span>
|
||||
{tt.stale}: {status.agent_created_skills.stale}
|
||||
</span>
|
||||
<span>
|
||||
{tt.archived}: {status.agent_created_skills.archived}
|
||||
</span>
|
||||
</span>
|
||||
</StatRow>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StatRow({
|
||||
label,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<span className="text-muted-foreground min-w-[6rem]">{label}</span>
|
||||
<span className="font-medium">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigCard() {
|
||||
const { t } = useI18n();
|
||||
const tt = t.settings.skillEvolution.config;
|
||||
const { config, isLoading } = useCuratorConfig();
|
||||
const updateConfig = useUpdateCuratorConfig();
|
||||
|
||||
const [draft, setDraft] = useState<CuratorConfig | null>(null);
|
||||
useEffect(() => {
|
||||
if (config && !draft) {
|
||||
setDraft(config);
|
||||
}
|
||||
}, [config, draft]);
|
||||
|
||||
if (isLoading || !draft) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{tt.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t.common.loading}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const isDirty =
|
||||
!!config &&
|
||||
JSON.stringify(draft) !== JSON.stringify(config);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const updated = await updateConfig.mutateAsync(draft);
|
||||
setDraft(updated);
|
||||
toast.success(tt.saveSuccess);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{tt.title}</CardTitle>
|
||||
<CardDescription>{tt.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<FieldRow
|
||||
label={tt.enabledLabel}
|
||||
helper={tt.enabledHelper}
|
||||
>
|
||||
<Switch
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(v) => setDraft({ ...draft, enabled: v })}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label={tt.curatorEnabledLabel}
|
||||
helper={tt.curatorEnabledHelper}
|
||||
>
|
||||
<Switch
|
||||
checked={draft.curator.enabled}
|
||||
onCheckedChange={(v) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
curator: { ...draft.curator, enabled: v },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label={tt.intervalHoursLabel}
|
||||
helper={tt.intervalHoursHelper}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="max-w-[10rem]"
|
||||
value={draft.curator.interval_hours}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
curator: {
|
||||
...draft.curator,
|
||||
interval_hours: Math.max(1, Number(e.target.value) || 1),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label={tt.staleAfterDaysLabel}
|
||||
helper={tt.staleAfterDaysHelper}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="max-w-[10rem]"
|
||||
value={draft.curator.stale_after_days}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
curator: {
|
||||
...draft.curator,
|
||||
stale_after_days: Math.max(1, Number(e.target.value) || 1),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label={tt.archiveAfterDaysLabel}
|
||||
helper={tt.archiveAfterDaysHelper}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="max-w-[10rem]"
|
||||
value={draft.curator.archive_after_days}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
curator: {
|
||||
...draft.curator,
|
||||
archive_after_days: Math.max(1, Number(e.target.value) || 1),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label={tt.moderationModelLabel}
|
||||
helper={tt.moderationModelHelper}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
className="max-w-[20rem]"
|
||||
value={draft.moderation_model_name ?? ""}
|
||||
placeholder=""
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
moderation_model_name:
|
||||
e.target.value.trim() === "" ? null : e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!isDirty || updateConfig.isPending}
|
||||
onClick={() => config && setDraft(config)}
|
||||
>
|
||||
{tt.reset}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!isDirty || updateConfig.isPending}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{updateConfig.isPending ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<SaveIcon className="size-4" />
|
||||
)}
|
||||
{tt.save}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldRow({
|
||||
label,
|
||||
helper,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
helper?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 border-b pb-4 last:border-b-0 last:pb-0">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
{helper && (
|
||||
<div className="text-muted-foreground mt-1 text-xs">{helper}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogsCard() {
|
||||
const { t } = useI18n();
|
||||
const tt = t.settings.skillEvolution.logs;
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const { logs, isLoading, refetch } = useCuratorLogs({ autoRefresh });
|
||||
|
||||
const records = logs?.records ?? [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>{tt.title}</CardTitle>
|
||||
<CardDescription>{tt.description}</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<Switch
|
||||
checked={autoRefresh}
|
||||
onCheckedChange={setAutoRefresh}
|
||||
/>
|
||||
{tt.autoRefresh}
|
||||
</label>
|
||||
<Button size="sm" variant="outline" onClick={() => void refetch()}>
|
||||
<RefreshCwIcon className="size-3.5" />
|
||||
{tt.refresh}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading && records.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">{tt.empty}</div>
|
||||
) : (
|
||||
<div className="bg-muted/30 max-h-[420px] overflow-auto rounded-md border p-3 font-mono text-xs leading-relaxed">
|
||||
{records.map((record, idx) => (
|
||||
<div
|
||||
key={`${record.ts}-${idx}`}
|
||||
className={cn(
|
||||
"flex gap-3",
|
||||
record.level === "warning" && "text-yellow-700 dark:text-yellow-300",
|
||||
record.level === "error" && "text-red-600 dark:text-red-300",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{formatTime(record.ts)}
|
||||
</span>
|
||||
<span className="text-muted-foreground w-14 shrink-0 uppercase">
|
||||
{record.level}
|
||||
</span>
|
||||
<span className="whitespace-pre-wrap break-all">
|
||||
{record.message === "run_started"
|
||||
? "——— 本次策展开始 ———"
|
||||
: record.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare } from "lucide-react";
|
||||
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
@@ -10,11 +11,14 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||
|
||||
export function WorkspaceNavChatList() {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
return (
|
||||
<SidebarGroup className="px-2 pt-2">
|
||||
<SidebarMenu>
|
||||
@@ -48,6 +52,17 @@ export function WorkspaceNavChatList() {
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/skills")}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/page/workspace/skills">
|
||||
<SparklesIcon />
|
||||
<span>技能</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||
@@ -59,6 +74,19 @@ export function WorkspaceNavChatList() {
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{isAdmin && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/admin")}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/page/workspace/admin/users">
|
||||
<UsersIcon />
|
||||
<span>{t.sidebar.users}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
import { NotificationBell } from "./notification-bell";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { SettingsDialog } from "./settings";
|
||||
@@ -56,7 +57,7 @@ export function WorkspaceNavMenu() {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
|
||||
"appearance" | "memory" | "tools" | "skills" | "notification" | "about"
|
||||
"appearance" | "memory" | "tools" | "notification" | "about"
|
||||
>("appearance");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
@@ -74,6 +75,9 @@ export function WorkspaceNavMenu() {
|
||||
defaultSection={settingsDefaultSection}
|
||||
/>
|
||||
<SidebarMenu className="w-full">
|
||||
<SidebarMenuItem>
|
||||
<NotificationBell />
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
{mounted ? (
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -3,6 +3,20 @@ import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { LoginByUsernameResponse } from "./index";
|
||||
|
||||
export interface UserListItem {
|
||||
id: string;
|
||||
email: string;
|
||||
system_role: "admin" | "user";
|
||||
skill_count: number;
|
||||
agent_count: number;
|
||||
}
|
||||
|
||||
export async function listAllUsers(): Promise<UserListItem[]> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json() as Promise<UserListItem[]>;
|
||||
}
|
||||
|
||||
export async function loginByUsername(
|
||||
username: string,
|
||||
): Promise<LoginByUsernameResponse> {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { listAllUsers } from "./api";
|
||||
|
||||
export function useAllUsers(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ["admin", "users"],
|
||||
queryFn: listAllUsers,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type {
|
||||
CuratorConfig,
|
||||
CuratorLogs,
|
||||
CuratorStatus,
|
||||
} from "./types";
|
||||
|
||||
export async function getCuratorStatus(): Promise<CuratorStatus> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/status`);
|
||||
return response.json() as Promise<CuratorStatus>;
|
||||
}
|
||||
|
||||
export async function getCuratorConfig(): Promise<CuratorConfig> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/config`);
|
||||
return response.json() as Promise<CuratorConfig>;
|
||||
}
|
||||
|
||||
export async function updateCuratorConfig(
|
||||
payload: CuratorConfig,
|
||||
): Promise<CuratorConfig> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/config`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return response.json() as Promise<CuratorConfig>;
|
||||
}
|
||||
|
||||
export async function runCurator(): Promise<{ triggered: boolean; message: string }> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/run`, {
|
||||
method: "POST",
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getCuratorLogs(): Promise<CuratorLogs> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/logs`);
|
||||
return response.json() as Promise<CuratorLogs>;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
|
||||
import {
|
||||
getCuratorConfig,
|
||||
getCuratorLogs,
|
||||
getCuratorStatus,
|
||||
runCurator,
|
||||
updateCuratorConfig,
|
||||
} from "./api";
|
||||
import type { CuratorConfig } from "./types";
|
||||
|
||||
// All curator-related queries are per-user data (status / config / logs all
|
||||
// live under .deer-flow/users/{user_id}/), so the cache key MUST be scoped by
|
||||
// user_id. Otherwise switching accounts in the same tab would let user B
|
||||
// briefly read user A's cached values — and clicking "save" on that stale
|
||||
// frame would silently overwrite user B's real config with user A's values.
|
||||
function useCuratorKeys() {
|
||||
const userId = useMemo(() => getStoredAuth()?.user_id ?? "anonymous", []);
|
||||
return useMemo(
|
||||
() => ({
|
||||
status: ["curator", userId, "status"] as const,
|
||||
config: ["curator", userId, "config"] as const,
|
||||
logs: ["curator", userId, "logs"] as const,
|
||||
}),
|
||||
[userId],
|
||||
);
|
||||
}
|
||||
|
||||
export function useCuratorStatus() {
|
||||
const keys = useCuratorKeys();
|
||||
const query = useQuery({
|
||||
queryKey: keys.status,
|
||||
queryFn: getCuratorStatus,
|
||||
});
|
||||
return { status: query.data, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
export function useCuratorConfig() {
|
||||
const keys = useCuratorKeys();
|
||||
const query = useQuery({
|
||||
queryKey: keys.config,
|
||||
queryFn: getCuratorConfig,
|
||||
});
|
||||
return { config: query.data, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
export function useUpdateCuratorConfig() {
|
||||
const queryClient = useQueryClient();
|
||||
const keys = useCuratorKeys();
|
||||
return useMutation({
|
||||
mutationFn: (payload: CuratorConfig) => updateCuratorConfig(payload),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: keys.config });
|
||||
void queryClient.invalidateQueries({ queryKey: keys.status });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRunCurator() {
|
||||
const queryClient = useQueryClient();
|
||||
const keys = useCuratorKeys();
|
||||
return useMutation({
|
||||
mutationFn: () => runCurator(),
|
||||
onSuccess: () => {
|
||||
// Logs start from scratch on a new run — refresh so the UI clears
|
||||
// immediately and then picks up the new run_started record.
|
||||
void queryClient.invalidateQueries({ queryKey: keys.logs });
|
||||
void queryClient.invalidateQueries({ queryKey: keys.status });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCuratorLogs(options?: { autoRefresh?: boolean }) {
|
||||
const autoRefresh = options?.autoRefresh ?? true;
|
||||
const keys = useCuratorKeys();
|
||||
const query = useQuery({
|
||||
queryKey: keys.logs,
|
||||
queryFn: getCuratorLogs,
|
||||
refetchInterval: autoRefresh ? 2000 : false,
|
||||
});
|
||||
return {
|
||||
logs: query.data,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface CuratorSubConfig {
|
||||
enabled: boolean;
|
||||
interval_hours: number;
|
||||
stale_after_days: number;
|
||||
archive_after_days: number;
|
||||
}
|
||||
|
||||
export interface CuratorConfig {
|
||||
enabled: boolean;
|
||||
moderation_model_name: string | null;
|
||||
curator: CuratorSubConfig;
|
||||
}
|
||||
|
||||
export interface CuratorStatus {
|
||||
enabled: boolean;
|
||||
run_count: number;
|
||||
last_run_at: string | null;
|
||||
last_run_summary: string | null;
|
||||
paused: boolean;
|
||||
agent_created_skills: {
|
||||
total: number;
|
||||
active: number;
|
||||
stale: number;
|
||||
archived: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CuratorLogRecord {
|
||||
ts: string;
|
||||
level: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface CuratorLogs {
|
||||
records: CuratorLogRecord[];
|
||||
run_count: number;
|
||||
last_run_at: string | null;
|
||||
}
|
||||
@@ -172,6 +172,7 @@ export const enUS: Translations = {
|
||||
recentChats: "Recent chats",
|
||||
demoChats: "Demo chats",
|
||||
agents: "Agents",
|
||||
users: "Users",
|
||||
},
|
||||
|
||||
// Agents
|
||||
@@ -434,10 +435,77 @@ export const enUS: Translations = {
|
||||
"Manage the configuration and enabled status of the agent skills.",
|
||||
createSkill: "Create skill",
|
||||
installSkill: "Install skill",
|
||||
goToEvolution: "Skill evolution",
|
||||
emptyTitle: "No agent skill yet",
|
||||
emptyDescription:
|
||||
"Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.",
|
||||
emptyButton: "Create Your First Skill",
|
||||
badgeBuiltin: "Built-in",
|
||||
badgeMine: "Mine",
|
||||
badgeFromOthers: "From others",
|
||||
badgeFromOthersTitle: "Owned by user {user_id}",
|
||||
badgePublished: "Published",
|
||||
badgePublishedTitle: "Visible to all users",
|
||||
badgeStale: "Stale",
|
||||
badgeStaleTitle: "Idle for a long time — flagged by the curator",
|
||||
badgeArchived: "Archived",
|
||||
badgeArchivedTitle: "Archived — recoverable from the archive drawer",
|
||||
badgePinned: "Pinned",
|
||||
badgePinnedTitle: "Pinned — the curator will never auto-archive this skill",
|
||||
badgeSourceAgent: "Agent-created",
|
||||
badgeSourceAgentTitle: "Auto-generated by an agent during a conversation",
|
||||
badgeSourceUpload: "Uploaded",
|
||||
badgeSourceUploadTitle: "Manually uploaded or edited by a user",
|
||||
tabAll: "All",
|
||||
tabMine: "Mine",
|
||||
entryStale: "Stale {count}",
|
||||
entryArchived: "Archived {count}",
|
||||
useCountInline: "Used {count} times",
|
||||
useCountInlineZero: "Not used yet",
|
||||
publishAction: "Publish",
|
||||
unpublishAction: "Unpublish",
|
||||
publishSuccess: "Skill published",
|
||||
unpublishSuccess: "Skill unpublished",
|
||||
pinAction: "Pin (prevent auto-archive)",
|
||||
unpinAction: "Unpin",
|
||||
pinSuccess: "Pinned",
|
||||
unpinSuccess: "Unpinned",
|
||||
deleteAction: "Delete skill",
|
||||
deleteConfirm:
|
||||
"Delete skill \"{name}\"? This cannot be undone and the skill will be removed from any agent that referenced it.",
|
||||
deleteSuccess: "Skill deleted",
|
||||
stateSheet: {
|
||||
archivedTitle: "Archived skills",
|
||||
archivedDescription:
|
||||
"These skills have been archived by you or the curator and can be restored at any time.",
|
||||
staleTitle: "Stale skills",
|
||||
staleDescription:
|
||||
"These skills have been idle for a while and were flagged by the curator. Using one again will auto-revive it; you can also act on them here.",
|
||||
empty: "Nothing here",
|
||||
actionRestore: "Restore",
|
||||
actionArchiveNow: "Archive now",
|
||||
actionView: "View",
|
||||
actionPin: "Pin",
|
||||
actionUnpin: "Unpin",
|
||||
actionDelete: "Delete permanently",
|
||||
moreMenu: "More",
|
||||
archivedAt: "Archived {when}",
|
||||
lastUsedAt: "Last used {when}",
|
||||
neverUsed: "Never used",
|
||||
restoreSuccess: "Restored",
|
||||
archiveSuccess: "Archived",
|
||||
pinSuccess: "Pinned",
|
||||
unpinSuccess: "Unpinned",
|
||||
},
|
||||
viewDialog: {
|
||||
title: "Skill details",
|
||||
descriptionLabel: "Description",
|
||||
stateLabel: "State",
|
||||
sourceLabel: "Source",
|
||||
useCountLabel: "Use count",
|
||||
lastUsedLabel: "Last used",
|
||||
close: "Close",
|
||||
},
|
||||
installDialog: {
|
||||
title: "Install skill",
|
||||
dropHint: "Drop a .zip or .skill archive here, or",
|
||||
@@ -456,9 +524,73 @@ export const enUS: Translations = {
|
||||
"A custom skill named '{name}' is already installed. Use 'Replace existing' to overwrite (its enabled state will be kept).",
|
||||
conflictPublic:
|
||||
"A built-in public skill named '{name}' already exists and cannot be overwritten.",
|
||||
conflictPublicAdminHint:
|
||||
"A built-in public skill named '{name}' exists. Switch to 'Built-in' and click 'Replace existing' to overwrite.",
|
||||
installSuccess: "Skill '{name}' installed.",
|
||||
installSuccessReplaced: "Skill '{name}' replaced.",
|
||||
rejectInvalidExt: "Only .zip or .skill files are accepted.",
|
||||
targetCategoryLabel: "Install to",
|
||||
categoryCustom: "Custom skill",
|
||||
categoryPublic: "Built-in (admin)",
|
||||
},
|
||||
},
|
||||
skillEvolution: {
|
||||
title: "Skill Evolution",
|
||||
description:
|
||||
"Manage the skill curator: let the agent age out, archive, and consolidate its self-created skills in the background.",
|
||||
backToSkills: "Back to skills",
|
||||
status: {
|
||||
title: "Status",
|
||||
enabled: "Enabled",
|
||||
disabled: "Disabled",
|
||||
paused: "Paused",
|
||||
runCount: "Total runs",
|
||||
runCountUnit: "",
|
||||
lastRunAt: "Last run",
|
||||
lastRunNever: "Never",
|
||||
lastRunSummary: "Last summary",
|
||||
lastRunSummaryEmpty: "—",
|
||||
skillCounts: "Skills",
|
||||
active: "Active",
|
||||
stale: "Stale",
|
||||
archived: "Archived",
|
||||
total: "Total",
|
||||
runNow: "Run now",
|
||||
runStarted: "Background run started",
|
||||
},
|
||||
config: {
|
||||
title: "Configuration",
|
||||
description:
|
||||
"Saves to skill_evolution.json and hot-reloads — no restart required.",
|
||||
enabledLabel: "Enable skill evolution",
|
||||
enabledHelper:
|
||||
"When off, the agent cannot create or modify skills under custom/.",
|
||||
curatorEnabledLabel: "Curator runs automatically",
|
||||
curatorEnabledHelper:
|
||||
"When off, the curator only runs when triggered from this page.",
|
||||
intervalHoursLabel: "Run interval (hours)",
|
||||
intervalHoursHelper:
|
||||
"Minimum hours between automatic curator runs.",
|
||||
staleAfterDaysLabel: "Stale threshold (days)",
|
||||
staleAfterDaysHelper:
|
||||
"Skills inactive for this many days are flagged stale.",
|
||||
archiveAfterDaysLabel: "Archive threshold (days)",
|
||||
archiveAfterDaysHelper:
|
||||
"Stale skills inactive for this many additional days are archived.",
|
||||
moderationModelLabel: "Moderation model (optional)",
|
||||
moderationModelHelper:
|
||||
"Leave blank to use the default chat model.",
|
||||
save: "Save changes",
|
||||
saveSuccess: "Configuration updated",
|
||||
reset: "Reset",
|
||||
},
|
||||
logs: {
|
||||
title: "Recent run logs",
|
||||
description:
|
||||
"Latest curator run only; cleared on backend restart.",
|
||||
empty: "No logs yet. Clicking 'Run now' will populate this panel.",
|
||||
autoRefresh: "Auto-refresh (2s)",
|
||||
refresh: "Refresh now",
|
||||
},
|
||||
},
|
||||
notification: {
|
||||
@@ -479,4 +611,26 @@ export const enUS: Translations = {
|
||||
emptyDescription: "Credits and acknowledgements will show here.",
|
||||
},
|
||||
},
|
||||
|
||||
adminUsers: {
|
||||
title: "User Management",
|
||||
description: "View all registered users and their associated skill and agent counts.",
|
||||
email: "Email",
|
||||
role: "Role",
|
||||
userId: "User ID",
|
||||
skillCount: "Skills",
|
||||
agentCount: "Agents",
|
||||
roleAdmin: "Admin",
|
||||
roleUser: "User",
|
||||
empty: "No users found",
|
||||
},
|
||||
|
||||
notifications: {
|
||||
title: "Notifications",
|
||||
bellAria: "Open notifications",
|
||||
unreadShort: "{count} unread",
|
||||
markAllRead: "Mark all read",
|
||||
empty: "No notifications",
|
||||
deleteAria: "Delete notification",
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,14 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
export interface NotificationsTranslations {
|
||||
title: string;
|
||||
bellAria: string;
|
||||
unreadShort: string;
|
||||
markAllRead: string;
|
||||
empty: string;
|
||||
deleteAria: string;
|
||||
}
|
||||
|
||||
export interface Translations {
|
||||
// Locale meta
|
||||
locale: {
|
||||
@@ -116,6 +125,7 @@ export interface Translations {
|
||||
chats: string;
|
||||
demoChats: string;
|
||||
agents: string;
|
||||
users: string;
|
||||
};
|
||||
|
||||
// Agents
|
||||
@@ -355,9 +365,73 @@ export interface Translations {
|
||||
description: string;
|
||||
createSkill: string;
|
||||
installSkill: string;
|
||||
goToEvolution: string;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
emptyButton: string;
|
||||
badgeBuiltin: string;
|
||||
badgeMine: string;
|
||||
badgeFromOthers: string;
|
||||
badgeFromOthersTitle: string;
|
||||
badgePublished: string;
|
||||
badgePublishedTitle: string;
|
||||
badgeStale: string;
|
||||
badgeStaleTitle: string;
|
||||
badgeArchived: string;
|
||||
badgeArchivedTitle: string;
|
||||
badgePinned: string;
|
||||
badgePinnedTitle: string;
|
||||
badgeSourceAgent: string;
|
||||
badgeSourceAgentTitle: string;
|
||||
badgeSourceUpload: string;
|
||||
badgeSourceUploadTitle: string;
|
||||
tabAll: string;
|
||||
tabMine: string;
|
||||
entryStale: string;
|
||||
entryArchived: string;
|
||||
useCountInline: string;
|
||||
useCountInlineZero: string;
|
||||
publishAction: string;
|
||||
unpublishAction: string;
|
||||
publishSuccess: string;
|
||||
unpublishSuccess: string;
|
||||
pinAction: string;
|
||||
unpinAction: string;
|
||||
pinSuccess: string;
|
||||
unpinSuccess: string;
|
||||
deleteAction: string;
|
||||
deleteConfirm: string;
|
||||
deleteSuccess: string;
|
||||
stateSheet: {
|
||||
archivedTitle: string;
|
||||
archivedDescription: string;
|
||||
staleTitle: string;
|
||||
staleDescription: string;
|
||||
empty: string;
|
||||
actionRestore: string;
|
||||
actionArchiveNow: string;
|
||||
actionView: string;
|
||||
actionPin: string;
|
||||
actionUnpin: string;
|
||||
actionDelete: string;
|
||||
moreMenu: string;
|
||||
archivedAt: string;
|
||||
lastUsedAt: string;
|
||||
neverUsed: string;
|
||||
restoreSuccess: string;
|
||||
archiveSuccess: string;
|
||||
pinSuccess: string;
|
||||
unpinSuccess: string;
|
||||
};
|
||||
viewDialog: {
|
||||
title: string;
|
||||
descriptionLabel: string;
|
||||
stateLabel: string;
|
||||
sourceLabel: string;
|
||||
useCountLabel: string;
|
||||
lastUsedLabel: string;
|
||||
close: string;
|
||||
};
|
||||
installDialog: {
|
||||
title: string;
|
||||
dropHint: string;
|
||||
@@ -374,9 +448,63 @@ export interface Translations {
|
||||
warningsLabel: string;
|
||||
conflictCustom: string;
|
||||
conflictPublic: string;
|
||||
conflictPublicAdminHint: string;
|
||||
installSuccess: string;
|
||||
installSuccessReplaced: string;
|
||||
rejectInvalidExt: string;
|
||||
targetCategoryLabel: string;
|
||||
categoryCustom: string;
|
||||
categoryPublic: string;
|
||||
};
|
||||
};
|
||||
skillEvolution: {
|
||||
title: string;
|
||||
description: string;
|
||||
backToSkills: string;
|
||||
status: {
|
||||
title: string;
|
||||
enabled: string;
|
||||
disabled: string;
|
||||
paused: string;
|
||||
runCount: string;
|
||||
runCountUnit: string;
|
||||
lastRunAt: string;
|
||||
lastRunNever: string;
|
||||
lastRunSummary: string;
|
||||
lastRunSummaryEmpty: string;
|
||||
skillCounts: string;
|
||||
active: string;
|
||||
stale: string;
|
||||
archived: string;
|
||||
total: string;
|
||||
runNow: string;
|
||||
runStarted: string;
|
||||
};
|
||||
config: {
|
||||
title: string;
|
||||
description: string;
|
||||
enabledLabel: string;
|
||||
enabledHelper: string;
|
||||
curatorEnabledLabel: string;
|
||||
curatorEnabledHelper: string;
|
||||
intervalHoursLabel: string;
|
||||
intervalHoursHelper: string;
|
||||
staleAfterDaysLabel: string;
|
||||
staleAfterDaysHelper: string;
|
||||
archiveAfterDaysLabel: string;
|
||||
archiveAfterDaysHelper: string;
|
||||
moderationModelLabel: string;
|
||||
moderationModelHelper: string;
|
||||
save: string;
|
||||
saveSuccess: string;
|
||||
reset: string;
|
||||
};
|
||||
logs: {
|
||||
title: string;
|
||||
description: string;
|
||||
empty: string;
|
||||
autoRefresh: string;
|
||||
refresh: string;
|
||||
};
|
||||
};
|
||||
notification: {
|
||||
@@ -395,4 +523,21 @@ export interface Translations {
|
||||
emptyDescription: string;
|
||||
};
|
||||
};
|
||||
|
||||
// Admin user management page
|
||||
adminUsers: {
|
||||
title: string;
|
||||
description: string;
|
||||
email: string;
|
||||
role: string;
|
||||
userId: string;
|
||||
skillCount: string;
|
||||
agentCount: string;
|
||||
roleAdmin: string;
|
||||
roleUser: string;
|
||||
empty: string;
|
||||
};
|
||||
|
||||
// In-app notification inbox (bell in the workspace sidebar)
|
||||
notifications: NotificationsTranslations;
|
||||
}
|
||||
@@ -166,6 +166,7 @@ export const zhCN: Translations = {
|
||||
recentChats: "最近的对话",
|
||||
demoChats: "演示对话",
|
||||
agents: "智能体",
|
||||
users: "用户管理",
|
||||
},
|
||||
|
||||
// Agents
|
||||
@@ -415,10 +416,74 @@ export const zhCN: Translations = {
|
||||
description: "管理 Agent Skill 配置和启用状态。",
|
||||
createSkill: "新建技能",
|
||||
installSkill: "安装技能",
|
||||
goToEvolution: "自我进化",
|
||||
emptyTitle: "还没有技能",
|
||||
emptyDescription:
|
||||
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
|
||||
emptyButton: "创建你的第一个技能",
|
||||
badgeBuiltin: "内置",
|
||||
badgeMine: "我的",
|
||||
badgeFromOthers: "来自他人",
|
||||
badgeFromOthersTitle: "来自用户 {user_id}",
|
||||
badgePublished: "已发布",
|
||||
badgePublishedTitle: "该技能已对所有用户可见",
|
||||
badgeStale: "过时",
|
||||
badgeStaleTitle: "长期未使用,已被策展程序标记",
|
||||
badgeArchived: "归档",
|
||||
badgeArchivedTitle: "已被归档,可在归档抽屉中恢复",
|
||||
badgePinned: "钉选",
|
||||
badgePinnedTitle: "已钉选,策展程序不会自动归档此技能",
|
||||
badgeSourceAgent: "Agent 创建",
|
||||
badgeSourceAgentTitle: "在对话中由 Agent 自动生成",
|
||||
badgeSourceUpload: "用户上传",
|
||||
badgeSourceUploadTitle: "由用户手动上传或编辑",
|
||||
tabAll: "全部",
|
||||
tabMine: "我的",
|
||||
entryStale: "过时 {count}",
|
||||
entryArchived: "归档 {count}",
|
||||
useCountInline: "调用 {count} 次",
|
||||
useCountInlineZero: "尚未调用",
|
||||
publishAction: "发布",
|
||||
unpublishAction: "取消发布",
|
||||
publishSuccess: "已发布",
|
||||
unpublishSuccess: "已取消发布",
|
||||
pinAction: "钉选(防止被自动归档)",
|
||||
unpinAction: "取消钉选",
|
||||
pinSuccess: "已钉选",
|
||||
unpinSuccess: "已取消钉选",
|
||||
deleteAction: "删除技能",
|
||||
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有专家中自动移除。",
|
||||
deleteSuccess: "技能已删除",
|
||||
stateSheet: {
|
||||
archivedTitle: "归档的技能",
|
||||
archivedDescription: "这些技能已被你或策展程序归档,可随时恢复。",
|
||||
staleTitle: "过时的技能",
|
||||
staleDescription: "这些技能长期未被使用,已被策展程序标记。重新使用一次会自动恢复活跃,你也可以主动处理。",
|
||||
empty: "暂无内容",
|
||||
actionRestore: "恢复",
|
||||
actionArchiveNow: "立即归档",
|
||||
actionView: "查看详情",
|
||||
actionPin: "钉选",
|
||||
actionUnpin: "取消钉选",
|
||||
actionDelete: "永久删除",
|
||||
moreMenu: "更多操作",
|
||||
archivedAt: "归档于 {when}",
|
||||
lastUsedAt: "最近使用 {when}",
|
||||
neverUsed: "尚未使用",
|
||||
restoreSuccess: "已恢复",
|
||||
archiveSuccess: "已归档",
|
||||
pinSuccess: "已钉选",
|
||||
unpinSuccess: "已取消钉选",
|
||||
},
|
||||
viewDialog: {
|
||||
title: "技能详情",
|
||||
descriptionLabel: "描述",
|
||||
stateLabel: "状态",
|
||||
sourceLabel: "来源",
|
||||
useCountLabel: "调用次数",
|
||||
lastUsedLabel: "最近使用",
|
||||
close: "关闭",
|
||||
},
|
||||
installDialog: {
|
||||
title: "安装技能",
|
||||
dropHint: "拖拽 .zip 或 .skill 文件到此处,或者",
|
||||
@@ -435,10 +500,66 @@ export const zhCN: Translations = {
|
||||
warningsLabel: "警告",
|
||||
conflictCustom:
|
||||
"已存在同名自定义技能 “{name}”。点击「覆盖已有」可替换(原启用状态保留)。",
|
||||
conflictPublic: "存在同名内置技能 “{name}”,无法覆盖。",
|
||||
installSuccess: "技能 “{name}” 已安装。",
|
||||
installSuccessReplaced: "技能 “{name}” 已替换。",
|
||||
rejectInvalidExt: "只接受 .zip 或 .skill 文件。",
|
||||
conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`,
|
||||
conflictPublicAdminHint: `存在同名内置技能 '{name}'。切换到「内置技能」并点击「覆盖已有」可替换。`,
|
||||
installSuccess: `技能 '{name}' 已安装。`,
|
||||
installSuccessReplaced: `技能 '{name}' 已替换。`,
|
||||
rejectInvalidExt: `只接受 .zip 或 .skill 文件。`,
|
||||
targetCategoryLabel: `安装到`,
|
||||
categoryCustom: `自定义技能`,
|
||||
categoryPublic: `内置技能(管理员)`,
|
||||
},
|
||||
},
|
||||
skillEvolution: {
|
||||
title: "自我进化",
|
||||
description:
|
||||
"管理技能 curator:让 agent 在后台对自创建的技能做老化标记、归档和合并建议。",
|
||||
backToSkills: "返回技能",
|
||||
status: {
|
||||
title: "当前状态",
|
||||
enabled: "已启用",
|
||||
disabled: "已停用",
|
||||
paused: "已暂停",
|
||||
runCount: "累计运行",
|
||||
runCountUnit: "次",
|
||||
lastRunAt: "上次运行",
|
||||
lastRunNever: "尚未运行",
|
||||
lastRunSummary: "上次摘要",
|
||||
lastRunSummaryEmpty: "—",
|
||||
skillCounts: "技能",
|
||||
active: "活跃",
|
||||
stale: "陈旧",
|
||||
archived: "已归档",
|
||||
total: "合计",
|
||||
runNow: "立即执行",
|
||||
runStarted: "已触发后台执行",
|
||||
},
|
||||
config: {
|
||||
title: "配置",
|
||||
description:
|
||||
"保存后会写入 skill_evolution.json 并热更新,无需重启。",
|
||||
enabledLabel: "启用自我进化",
|
||||
enabledHelper: "关闭后 agent 不会创建或修改 custom/ 下的技能。",
|
||||
curatorEnabledLabel: "curator 自动运行",
|
||||
curatorEnabledHelper: "关闭后只能在此页面手动触发。",
|
||||
intervalHoursLabel: "运行间隔(小时)",
|
||||
intervalHoursHelper: "两次自动运行之间至少经过的小时数。",
|
||||
staleAfterDaysLabel: "陈旧阈值(天)",
|
||||
staleAfterDaysHelper: "技能长时间不活跃后被标记为陈旧。",
|
||||
archiveAfterDaysLabel: "归档阈值(天)",
|
||||
archiveAfterDaysHelper: "标记陈旧后再经过这么多天会被自动归档。",
|
||||
moderationModelLabel: "安全审核模型(可选)",
|
||||
moderationModelHelper: "为空时使用默认主模型。",
|
||||
save: "保存修改",
|
||||
saveSuccess: "配置已更新",
|
||||
reset: "重置",
|
||||
},
|
||||
logs: {
|
||||
title: "最近运行日志",
|
||||
description: "展示最近一次 curator 运行的日志,服务重启会清空。",
|
||||
empty: "暂无日志。点击「立即执行」会写入新的运行记录。",
|
||||
autoRefresh: "自动刷新 (2s)",
|
||||
refresh: "立即刷新",
|
||||
},
|
||||
},
|
||||
notification: {
|
||||
@@ -459,4 +580,26 @@ export const zhCN: Translations = {
|
||||
emptyDescription: "相关的致谢信息会展示在这里。",
|
||||
},
|
||||
},
|
||||
|
||||
adminUsers: {
|
||||
title: "用户管理",
|
||||
description: "查看所有注册用户及其关联的技能和智能体数量。",
|
||||
email: "邮箱",
|
||||
role: "角色",
|
||||
userId: "用户 ID",
|
||||
skillCount: "技能数",
|
||||
agentCount: "智能体数",
|
||||
roleAdmin: "管理员",
|
||||
roleUser: "普通用户",
|
||||
empty: "暂无用户",
|
||||
},
|
||||
|
||||
notifications: {
|
||||
title: "通知",
|
||||
bellAria: "打开通知",
|
||||
unreadShort: "{count} 条未读",
|
||||
markAllRead: "全部已读",
|
||||
empty: "暂无通知",
|
||||
deleteAria: "删除通知",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type {
|
||||
NotificationItem,
|
||||
NotificationListResponse,
|
||||
NotificationUnreadCountResponse,
|
||||
} from "./types";
|
||||
|
||||
async function parseDetail(res: Response, fallback: string): Promise<Error> {
|
||||
const data = (await res.json().catch(() => ({}))) as { detail?: unknown };
|
||||
const detail = typeof data.detail === "string" && data.detail ? data.detail : null;
|
||||
return new Error(detail ?? `${fallback} (HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
export async function listNotifications(
|
||||
options: { unreadOnly?: boolean; limit?: number } = {},
|
||||
): Promise<NotificationItem[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.unreadOnly) params.set("unread_only", "true");
|
||||
if (options.limit) params.set("limit", String(options.limit));
|
||||
const query = params.toString();
|
||||
const url = `${getBackendBaseURL()}/api/notifications${query ? `?${query}` : ""}`;
|
||||
const res = await apiFetch(url);
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to list notifications");
|
||||
const data = (await res.json()) as NotificationListResponse;
|
||||
return data.items;
|
||||
}
|
||||
|
||||
export async function getUnreadCount(): Promise<number> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/notifications/unread_count`);
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to load unread count");
|
||||
const data = (await res.json()) as NotificationUnreadCountResponse;
|
||||
return data.unread;
|
||||
}
|
||||
|
||||
export async function markNotificationRead(id: string): Promise<void> {
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/notifications/${encodeURIComponent(id)}/read`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to mark notification read");
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead(): Promise<number> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/notifications/read_all`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to mark all read");
|
||||
const data = (await res.json()) as { updated: number };
|
||||
return data.updated;
|
||||
}
|
||||
|
||||
export async function deleteNotification(id: string): Promise<void> {
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/notifications/${encodeURIComponent(id)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to delete notification");
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
|
||||
import {
|
||||
deleteNotification,
|
||||
getUnreadCount,
|
||||
listNotifications,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
} from "./api";
|
||||
|
||||
// Notifications are per-user data, so the cache key MUST be scoped by user_id.
|
||||
// Otherwise switching accounts in the same tab would show the previous user's
|
||||
// inbox until the next fetch lands.
|
||||
function useNotificationKeys() {
|
||||
const userId = useMemo(() => getStoredAuth()?.user_id ?? "anonymous", []);
|
||||
return useMemo(() => {
|
||||
const root = ["notifications", userId] as const;
|
||||
return {
|
||||
root,
|
||||
list: (limit: number, unreadOnly: boolean) =>
|
||||
[...root, "list", { limit, unreadOnly }] as const,
|
||||
count: [...root, "unread-count"] as const,
|
||||
};
|
||||
}, [userId]);
|
||||
}
|
||||
|
||||
export function useNotifications(options: { limit?: number; unreadOnly?: boolean } = {}) {
|
||||
const { limit = 20, unreadOnly = false } = options;
|
||||
const keys = useNotificationKeys();
|
||||
return useQuery({
|
||||
queryKey: keys.list(limit, unreadOnly),
|
||||
queryFn: () => listNotifications({ limit, unreadOnly }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnreadCount(refetchInterval = 30000) {
|
||||
const keys = useNotificationKeys();
|
||||
return useQuery({
|
||||
queryKey: keys.count,
|
||||
queryFn: () => getUnreadCount(),
|
||||
refetchInterval,
|
||||
// Keep the badge fresh when the user comes back to the tab without
|
||||
// waiting for the next interval tick.
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
function useInvalidateNotifications() {
|
||||
const queryClient = useQueryClient();
|
||||
const keys = useNotificationKeys();
|
||||
return () => {
|
||||
void queryClient.invalidateQueries({ queryKey: keys.root });
|
||||
};
|
||||
}
|
||||
|
||||
export function useMarkNotificationRead() {
|
||||
const invalidate = useInvalidateNotifications();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => markNotificationRead(id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkAllNotificationsRead() {
|
||||
const invalidate = useInvalidateNotifications();
|
||||
return useMutation({
|
||||
mutationFn: () => markAllNotificationsRead(),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteNotification() {
|
||||
const invalidate = useInvalidateNotifications();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => deleteNotification(id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./api";
|
||||
export * from "./hooks";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface NotificationItem {
|
||||
id: string;
|
||||
user_id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
body: string;
|
||||
payload: Record<string, unknown>;
|
||||
read: boolean;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface NotificationListResponse {
|
||||
items: NotificationItem[];
|
||||
}
|
||||
|
||||
export interface NotificationUnreadCountResponse {
|
||||
unread: number;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { apiFetch } from "@/core/api";
|
||||
|
||||
import type { Skill } from "./type";
|
||||
import type { Skill, SkillState } from "./type";
|
||||
|
||||
export async function loadSkills() {
|
||||
const skills = await apiFetch(`${getBackendBaseURL()}/api/skills`);
|
||||
@@ -96,11 +96,14 @@ export async function validateSkillUpload(file: File): Promise<ValidateSkillUplo
|
||||
|
||||
export async function installSkillUpload(
|
||||
file: File,
|
||||
options: { overwrite?: boolean } = {},
|
||||
options: { overwrite?: boolean; targetCategory?: "public" | "custom" } = {},
|
||||
): Promise<InstallSkillResponse> {
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name);
|
||||
const query = options.overwrite ? "?overwrite=true" : "";
|
||||
const params = new URLSearchParams();
|
||||
if (options.overwrite) params.set("overwrite", "true");
|
||||
if (options.targetCategory) params.set("target_category", options.targetCategory);
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/install-upload${query}`,
|
||||
{ method: "POST", body: form },
|
||||
@@ -110,3 +113,73 @@ export async function installSkillUpload(
|
||||
}
|
||||
return response.json() as Promise<InstallSkillResponse>;
|
||||
}
|
||||
|
||||
export interface SetSkillPublishedResponse {
|
||||
name: string;
|
||||
owner_user_id: string | null;
|
||||
published: boolean;
|
||||
}
|
||||
|
||||
export async function setSkillPublished(
|
||||
skillName: string,
|
||||
published: boolean,
|
||||
): Promise<SetSkillPublishedResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/published`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ published }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to update published state");
|
||||
}
|
||||
return response.json() as Promise<SetSkillPublishedResponse>;
|
||||
}
|
||||
|
||||
export async function setSkillState(
|
||||
skillName: string,
|
||||
state: SkillState,
|
||||
): Promise<Skill> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/state`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ state }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to update skill state");
|
||||
}
|
||||
return response.json() as Promise<Skill>;
|
||||
}
|
||||
|
||||
export async function setSkillPinned(
|
||||
skillName: string,
|
||||
pinned: boolean,
|
||||
): Promise<Skill> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/pinned`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pinned }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to update skill pinned state");
|
||||
}
|
||||
return response.json() as Promise<Skill>;
|
||||
}
|
||||
|
||||
export async function deleteCustomSkill(skillName: string): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to delete skill");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { enableSkill, installSkillUpload, validateSkillUpload } from "./api";
|
||||
import {
|
||||
deleteCustomSkill,
|
||||
enableSkill,
|
||||
installSkillUpload,
|
||||
setSkillPinned,
|
||||
setSkillPublished,
|
||||
setSkillState,
|
||||
validateSkillUpload,
|
||||
} from "./api";
|
||||
import type { SkillState } from "./type";
|
||||
|
||||
import { loadSkills } from ".";
|
||||
|
||||
@@ -39,8 +48,56 @@ export function useValidateSkillUpload() {
|
||||
export function useInstallSkillUpload() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ file, overwrite }: { file: File; overwrite?: boolean }) =>
|
||||
installSkillUpload(file, { overwrite }),
|
||||
mutationFn: ({ file, overwrite, targetCategory }: { file: File; overwrite?: boolean; targetCategory?: "public" | "custom" }) =>
|
||||
installSkillUpload(file, { overwrite, targetCategory }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSkillPublished() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
skillName,
|
||||
published,
|
||||
}: {
|
||||
skillName: string;
|
||||
published: boolean;
|
||||
}) => setSkillPublished(skillName, published),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSkillState() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ skillName, state }: { skillName: string; state: SkillState }) =>
|
||||
setSkillState(skillName, state),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSkillPinned() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ skillName, pinned }: { skillName: string; pinned: boolean }) =>
|
||||
setSkillPinned(skillName, pinned),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCustomSkill() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (skillName: string) => deleteCustomSkill(skillName),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
},
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
export interface Skill {
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
license: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
export type SkillState = "active" | "stale" | "archived";
|
||||
export type SkillSource = "agent" | "upload";
|
||||
|
||||
export interface Skill {
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
license: string;
|
||||
enabled: boolean;
|
||||
// Multitenancy fields — owner_user_id is null for built-in / public skills.
|
||||
// ``builtin`` mirrors that nullness for explicit branching on the UI side.
|
||||
owner_user_id?: string | null;
|
||||
published?: boolean;
|
||||
builtin?: boolean;
|
||||
// Lifecycle fields — populated by curator. The main list filters on these
|
||||
// so stale / archived skills appear in dedicated drawers instead.
|
||||
state?: SkillState;
|
||||
pinned?: boolean;
|
||||
// Provenance — distinguishes skills created by an agent during a chat
|
||||
// (``agent``) from skills the user uploaded / authored manually (``upload``).
|
||||
source?: SkillSource;
|
||||
last_activity_at?: string | null;
|
||||
use_count?: number;
|
||||
view_count?: number;
|
||||
patch_count?: number;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
@@ -17,13 +17,6 @@ import {
|
||||
} from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
@@ -120,7 +113,7 @@ export default function NewAgentPage() {
|
||||
model_name: chatModelInput || undefined,
|
||||
},
|
||||
onToolEnd() {
|
||||
// setup_agent never actually executes in this flow — it gets
|
||||
// setup_agent never actually executes in this flow 鈥?it gets
|
||||
// intercepted by SetupAgentApprovalMiddleware and the form is filled
|
||||
// from the approval ToolMessage payload (handleApproveSetupAgent).
|
||||
// No agent record is fetched/refreshed during chat.
|
||||
@@ -156,7 +149,7 @@ export default function NewAgentPage() {
|
||||
if (!name) {
|
||||
// Names are display-only and may collide, so a timestamped placeholder
|
||||
// is safe to send to the backend just to satisfy the required-field
|
||||
// validation. Intentionally NOT written back into the visible input —
|
||||
// validation. Intentionally NOT written back into the visible input 鈥?
|
||||
// the user wants the name box to stay blank while they chat, and we
|
||||
// refill it later (with the model's proposed name) on setup_agent
|
||||
// approval.
|
||||
@@ -245,7 +238,7 @@ export default function NewAgentPage() {
|
||||
// Chat is purely a discussion channel. The agent record is only
|
||||
// created when the user explicitly clicks Save (top-right). If the
|
||||
// model proposes a config via setup_agent, our approval handler
|
||||
// prefills the form locally — it does NOT persist anything.
|
||||
// prefills the form locally 鈥?it does NOT persist anything.
|
||||
await ensureThreadExists();
|
||||
await sendMessage(threadId, { text: trimmed, files: [] });
|
||||
},
|
||||
@@ -255,7 +248,7 @@ export default function NewAgentPage() {
|
||||
const handleApproveSetupAgent = useCallback(
|
||||
async (payload: SetupAgentApprovalPayload) => {
|
||||
// Approving the model's setup_agent proposal **does not** persist
|
||||
// anything — it only mirrors the proposal into the form so the user
|
||||
// anything 鈥?it only mirrors the proposal into the form so the user
|
||||
// can review it. Creation/update happens later when the user clicks
|
||||
// the top-right Save button.
|
||||
const proposedName = payload.name?.trim() ?? "";
|
||||
@@ -338,22 +331,23 @@ export default function NewAgentPage() {
|
||||
<span className="text-muted-foreground text-xs">
|
||||
当前对话模型
|
||||
</span>
|
||||
<Select
|
||||
<select
|
||||
value={chatModelInput}
|
||||
onValueChange={setChatModelInput}
|
||||
onChange={(event) =>
|
||||
setChatModelInput(event.currentTarget.value)
|
||||
}
|
||||
disabled={isModelsLoading || thread.isLoading}
|
||||
className="border-input bg-background h-7 min-w-[10rem] rounded-md border px-2 text-xs shadow-xs outline-none"
|
||||
>
|
||||
<SelectTrigger className="h-7 w-auto min-w-[10rem] gap-2 px-2 text-xs">
|
||||
<SelectValue placeholder="选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.name} value={model.name}>
|
||||
{model.display_name || model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<option value="" disabled>
|
||||
选择模型
|
||||
</option>
|
||||
{models.map((model) => (
|
||||
<option key={model.name} value={model.name}>
|
||||
{model.display_name || model.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<PromptInput
|
||||
onSubmit={({ text }) => void handleChatSubmit(text)}
|
||||
@@ -405,22 +399,21 @@ export default function NewAgentPage() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">模型</label>
|
||||
<Select
|
||||
<select
|
||||
value={modelInput}
|
||||
onValueChange={setModelInput}
|
||||
onChange={(event) => setModelInput(event.currentTarget.value)}
|
||||
disabled={isModelsLoading}
|
||||
className="border-input bg-background h-9 w-full rounded-md border px-3 text-sm shadow-xs outline-none"
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.name} value={model.name}>
|
||||
{model.display_name || model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<option value="" disabled>
|
||||
选择模型
|
||||
</option>
|
||||
{models.map((model) => (
|
||||
<option key={model.name} value={model.name}>
|
||||
{model.display_name || model.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
该专家被使用时调用的模型。
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SkillEvolutionPanel } from "@/components/workspace/skills/skill-evolution-panel";
|
||||
|
||||
export default function SkillEvolutionPage() {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-auto p-6">
|
||||
<SkillEvolutionPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
|
||||
|
||||
export default function SkillsPage() {
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-auto p-6">
|
||||
<SkillSettingsPage />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { RouteBaseProvider } from "@/core/route-base";
|
||||
import { SubtasksProvider } from "@/core/tasks/context";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
|
||||
import GeneralQA from "@/strategy-components/components/qa/GeneralQA";
|
||||
import { ChatRuntime } from "./WorkspaceRoutes";
|
||||
import AgentsPage from "./AgentsPage";
|
||||
import ChatsPage from "./ChatsPage";
|
||||
import ChatPage from "./ChatPage";
|
||||
@@ -18,19 +16,10 @@ import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||
|
||||
function ChatRuntime({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SubtasksProvider>
|
||||
<ArtifactsProvider>
|
||||
<PromptInputProvider>{children}</PromptInputProvider>
|
||||
</ArtifactsProvider>
|
||||
</SubtasksProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StrategyRoutes() {
|
||||
return (
|
||||
<RouteBaseProvider value="/page/strategy">
|
||||
<SidebarProvider>
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="qa/general" replace />} />
|
||||
@@ -89,6 +78,7 @@ export default function StrategyRoutes() {
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
<Toaster position="top-center" />
|
||||
</RouteBaseProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { UserManagementPage } from "@/components/workspace/admin/user-management-page";
|
||||
|
||||
export default function UsersPage() {
|
||||
return <UserManagementPage />;
|
||||
}
|
||||
@@ -22,6 +22,9 @@ import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
import LlmMetricsPage from "./LlmMetricsPage";
|
||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||
import SkillEvolutionPage from "./SkillEvolutionPage";
|
||||
import SkillsPage from "./SkillsPage";
|
||||
import UsersPage from "./UsersPage";
|
||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||
|
||||
export function WorkspaceLayout({
|
||||
@@ -63,7 +66,7 @@ export function WorkspaceLayout({
|
||||
);
|
||||
}
|
||||
|
||||
function ChatRuntime({ children }: { children: ReactNode }) {
|
||||
export function ChatRuntime({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SubtasksProvider>
|
||||
<ArtifactsProvider>
|
||||
@@ -136,6 +139,22 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="skills"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<SkillsPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="skills/evolution"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<SkillEvolutionPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="scheduled"
|
||||
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
|
||||
@@ -156,6 +175,14 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/users"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<UsersPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/recommended-questions"
|
||||
element={
|
||||
|
||||
Reference in new issue
Block a user