chore: ARTEX 初始化

ARTEX — AI 自主渗透测试系统(Go 后端 + Next.js 前端,单二进制内嵌)。

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Autumn-27andClaude Opus 4.8 committed 2026-07-26 04:27:45 -04:00
commit 0ef8561411
287 files changed
+70178

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
import type { ReactNode } from "react";
export default function AuthLayout({ children }: { readonly children: ReactNode }) {
return <>{children}</>;
}
+103
View File
@@ -0,0 +1,103 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/lib/api";
import { auth } from "@/lib/auth";
export default function LoginPage() {
const router = useRouter();
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [checking, setChecking] = useState(true);
useEffect(() => {
// 已登录直接进主界面(静态导出下无 middleware 代劳这层跳转)。
if (auth.getToken()) {
router.replace("/function/tasks");
return;
}
api.authStatus()
.then(({ initialized }) => {
if (!initialized) router.replace("/setup");
})
.catch(() => setError("无法连接到后端服务"))
.finally(() => setChecking(false));
}, [router]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
try {
const { token } = await api.login("ARTEX", password);
auth.setToken(token);
router.replace("/function/tasks");
} catch {
setError("用户名或密码错误");
} finally {
setLoading(false);
}
}
if (checking) return null;
return (
<div className="flex h-dvh">
{/* Left panel */}
<div className="hidden flex-col items-center justify-center bg-primary p-12 text-center lg:flex lg:w-1/3">
<div className="relative flex items-center justify-center">
<div className="absolute size-80 rounded-full border border-primary-foreground/10" />
<div className="absolute size-60 rounded-full border border-primary-foreground/15" />
<div className="absolute size-40 rounded-full border border-primary-foreground/20" />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/logo.png"
alt="ARTEX"
width={160}
height={160}
className="relative brightness-0 invert"
/>
</div>
</div>
{/* Right panel */}
<div className="flex w-full items-center justify-center bg-background p-8 lg:w-2/3">
<div className="w-full max-w-md space-y-10 py-24 lg:py-32">
<div className="space-y-4 text-center">
<h2 className="text-2xl font-medium tracking-tight">登录</h2>
<p className="mx-auto max-w-xl text-muted-foreground">欢迎回来,请输入密码以继续使用 ARTEX</p>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="space-y-1.5">
<Label htmlFor="username">用户名</Label>
<Input id="username" value="ARTEX" readOnly className="bg-muted text-muted-foreground" />
</div>
<div className="space-y-1.5">
<Label htmlFor="password">密码</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
autoFocus
autoComplete="current-password"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading || !password}>
{loading ? "登录中..." : "登录"}
</Button>
</form>
</div>
</div>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/lib/api";
import { auth } from "@/lib/auth";
export default function SetupPage() {
const router = useRouter();
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [checking, setChecking] = useState(true);
useEffect(() => {
api.authStatus()
.then(({ initialized }) => {
if (initialized) router.replace("/login");
})
.catch(() => setError("无法连接到后端服务"))
.finally(() => setChecking(false));
}, [router]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (password !== confirm) {
setError("两次输入的密码不一致");
return;
}
if (password.length < 8) {
setError("密码长度至少 8 位");
return;
}
setLoading(true);
setError("");
try {
const { token } = await api.initPassword(password);
auth.setToken(token);
router.replace("/function/tasks");
} catch (err) {
setError(err instanceof Error ? err.message : "初始化失败");
} finally {
setLoading(false);
}
}
if (checking) return null;
return (
<div className="flex h-dvh">
{/* Left panel */}
<div className="hidden flex-col items-center justify-center bg-primary p-12 text-center lg:flex lg:w-1/3">
<div className="relative flex items-center justify-center">
<div className="absolute size-80 rounded-full border border-primary-foreground/10" />
<div className="absolute size-60 rounded-full border border-primary-foreground/15" />
<div className="absolute size-40 rounded-full border border-primary-foreground/20" />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/logo.png"
alt="ARTEX"
width={160}
height={160}
className="relative brightness-0 invert"
/>
</div>
</div>
{/* Right panel */}
<div className="flex w-full items-center justify-center bg-background p-8 lg:w-2/3">
<div className="w-full max-w-md space-y-10 py-24 lg:py-32">
<div className="space-y-4 text-center">
<h2 className="text-2xl font-medium tracking-tight">初始化密码</h2>
<p className="mx-auto max-w-xl text-muted-foreground">首次使用 ARTEX,请为账户设置一个登录密码(至少 8 位)</p>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="space-y-1.5">
<Label htmlFor="password">新密码</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="至少 8 位"
autoFocus
autoComplete="new-password"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm">确认密码</Label>
<Input
id="confirm"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="再次输入密码"
autoComplete="new-password"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading || !password || !confirm}>
{loading ? "保存中..." : "设置密码并登录"}
</Button>
</form>
</div>
</div>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/function/tasks");
return <>Coming Soon</>;
}
@@ -0,0 +1,66 @@
"use client";
import type { ReactNode } from "react";
import { usePathname } from "next/navigation";
import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
import { useCurrentUser } from "@/hooks/use-current-user";
import { AccountSwitcher } from "./sidebar/account-switcher";
import { LayoutControls } from "./sidebar/layout-controls";
import { SearchDialog } from "./sidebar/search-dialog";
import { ThemeSwitcher } from "./sidebar/theme-switcher";
// 任务详情页保持原样:它自带头部/Tabs 与内边距,这里不再叠加全局头部和 padding。
function isFullBleed(pathname: string) {
const p = (() => {
try {
return decodeURIComponent(pathname);
} catch {
return pathname;
}
})();
return p.startsWith("/function/tasks/");
}
export function MainContent({ children }: { children: ReactNode }) {
const currentUser = useCurrentUser();
const pathname = usePathname();
if (isFullBleed(pathname)) {
return <>{children}</>;
}
return (
<>
<header
className={cn(
"flex h-12 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12",
"[html[data-navbar-style=sticky]_&]:sticky [html[data-navbar-style=sticky]_&]:top-0 [html[data-navbar-style=sticky]_&]:z-50 [html[data-navbar-style=sticky]_&]:overflow-hidden [html[data-navbar-style=sticky]_&]:rounded-t-[inherit] [html[data-navbar-style=sticky]_&]:bg-background/50 [html[data-navbar-style=sticky]_&]:backdrop-blur-md",
)}
>
<div className="flex w-full items-center justify-between px-4 lg:px-6">
<div className="flex items-center gap-1 lg:gap-2">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mx-2 data-[orientation=vertical]:h-4 data-[orientation=vertical]:self-center"
/>
<SearchDialog />
</div>
<div className="flex items-center gap-2">
<LayoutControls />
<ThemeSwitcher />
<AccountSwitcher users={[currentUser]} />
</div>
</div>
</header>
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden p-4 has-data-[content-padding=false]:p-0 md:p-6 md:has-data-[content-padding=false]:p-0">
{children}
</div>
</>
);
}
@@ -0,0 +1,94 @@
"use client";
import { useState } from "react";
import { Check, KeyRound, LogOut } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { auth } from "@/lib/auth";
import { cn, getInitials } from "@/lib/utils";
import { ChangePasswordDialog } from "./change-password-dialog";
export function AccountSwitcher({
users,
}: {
readonly users: ReadonlyArray<{
readonly id: string;
readonly name: string;
readonly email: string;
readonly avatar: string;
readonly role: string;
}>;
}) {
const [activeUser, setActiveUser] = useState(users[0]);
const [pwOpen, setPwOpen] = useState(false);
function handleLogout() {
auth.clearToken();
window.location.href = "/login";
}
if (!activeUser) {
return null;
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Avatar className="size-8 rounded-lg">
<AvatarImage src={activeUser.avatar || undefined} alt={activeUser.name} />
<AvatarFallback>{getInitials(activeUser.name)}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-56 space-y-1 rounded-lg" side="bottom" align="end" sideOffset={4}>
{users.map((user) => (
<DropdownMenuItem
key={user.email}
className={cn("p-0", user.id === activeUser.id && "bg-accent/50")}
aria-current={user.id === activeUser.id ? "true" : undefined}
onClick={() => setActiveUser(user)}
>
<div className="flex w-full items-center gap-2 px-1 py-1.5">
<Avatar className="size-9 rounded-lg">
<AvatarImage src={user.avatar || undefined} alt={user.name} />
<AvatarFallback>{getInitials(user.name)}</AvatarFallback>
</Avatar>
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{user.name}</span>
<span className="truncate text-xs capitalize">{user.role}</span>
</div>
<span
className={cn(
"mr-1 flex size-5 items-center justify-center rounded-full text-primary opacity-0",
user.id === activeUser.id && "opacity-100",
)}
>
<Check aria-hidden="true" />
</span>
</div>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setPwOpen(true)}>
<KeyRound />
修改密码
</DropdownMenuItem>
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
<LogOut />
退出登录
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ChangePasswordDialog open={pwOpen} onOpenChange={setPwOpen} />
</>
);
}
@@ -0,0 +1,64 @@
"use client";
import Link from "next/link";
import { useShallow } from "zustand/react/shallow";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { APP_CONFIG } from "@/config/app-config";
import { useCurrentUser } from "@/hooks/use-current-user";
import { sidebarItems } from "@/navigation/sidebar/sidebar-items";
import { usePreferencesStore } from "@/stores/preferences/preferences-provider";
import { NavMain } from "./nav-main";
import { NavUser } from "./nav-user";
import { SidebarSupportCard } from "./sidebar-support-card";
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const currentUser = useCurrentUser();
const { sidebarVariant, sidebarCollapsible, isSynced } = usePreferencesStore(
useShallow((s) => ({
sidebarVariant: s.sidebarVariant,
sidebarCollapsible: s.sidebarCollapsible,
isSynced: s.isSynced,
})),
);
const variant = isSynced ? sidebarVariant : props.variant;
const collapsible = isSynced ? sidebarCollapsible : props.collapsible;
return (
<Sidebar {...props} variant={variant} collapsible={collapsible}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Link prefetch={false} href="/function/tasks">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/logo.png" alt="ARTEX" width={40} height={40} className="shrink-0" />
<span className="font-semibold text-base">{APP_CONFIG.name}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={sidebarItems} />
{/* <NavDocuments items={data.documents} /> */}
{/* <NavSecondary items={data.navSecondary} className="mt-auto" /> */}
</SidebarContent>
<SidebarFooter>
<NavUser user={currentUser} />
</SidebarFooter>
</Sidebar>
);
}
@@ -0,0 +1,119 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/lib/api";
export function ChangePasswordDialog({
open,
onOpenChange,
}: {
readonly open: boolean;
readonly onOpenChange: (open: boolean) => void;
}) {
const [oldPassword, setOldPassword] = React.useState("");
const [newPassword, setNewPassword] = React.useState("");
const [confirmPassword, setConfirmPassword] = React.useState("");
const [saving, setSaving] = React.useState(false);
// reset fields whenever the dialog closes
React.useEffect(() => {
if (!open) {
setOldPassword("");
setNewPassword("");
setConfirmPassword("");
}
}, [open]);
function submit(e: React.FormEvent) {
e.preventDefault();
if (!oldPassword || !newPassword) {
toast.error("请填写当前密码和新密码");
return;
}
if (newPassword !== confirmPassword) {
toast.error("两次输入的新密码不一致");
return;
}
setSaving(true);
api
.changePassword(oldPassword, newPassword)
.then(() => {
toast.success("密码已修改");
onOpenChange(false);
})
.catch((err) => toast.error(`修改失败:${(err as Error).message}`))
.finally(() => setSaving(false));
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<form onSubmit={submit}>
<DialogHeader>
<DialogTitle>修改密码</DialogTitle>
<DialogDescription>
用户名固定为 <b>ARTEX</b>。需先输入当前密码验证;修改后已签发的登录 token 仍有效直至过期。
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 py-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="cp-old">当前密码</Label>
<Input
id="cp-old"
type="password"
autoComplete="current-password"
value={oldPassword}
disabled={saving}
onChange={(e) => setOldPassword(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cp-new">新密码</Label>
<Input
id="cp-new"
type="password"
autoComplete="new-password"
value={newPassword}
disabled={saving}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cp-confirm">确认新密码</Label>
<Input
id="cp-confirm"
type="password"
autoComplete="new-password"
value={confirmPassword}
disabled={saving}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
取消
</Button>
<Button type="submit" disabled={saving}>
{saving ? "修改中…" : "确认修改"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,265 @@
"use client";
import { Settings } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { type FontKey, fontOptions } from "@/lib/fonts/registry";
import type { ContentLayout, NavbarStyle, SidebarCollapsible, SidebarVariant } from "@/lib/preferences/layout";
import {
applyContentLayout,
applyFont,
applyNavbarStyle,
applySidebarCollapsible,
applySidebarVariant,
} from "@/lib/preferences/layout-utils";
import { PREFERENCE_DEFAULTS } from "@/lib/preferences/preferences-config";
import { persistPreference } from "@/lib/preferences/preferences-storage";
import { THEME_PRESET_OPTIONS, type ThemeMode, type ThemePreset } from "@/lib/preferences/theme";
import { applyThemePreset } from "@/lib/preferences/theme-utils";
import { usePreferencesStore } from "@/stores/preferences/preferences-provider";
export function LayoutControls() {
const themeMode = usePreferencesStore((s) => s.themeMode);
const resolvedThemeMode = usePreferencesStore((s) => s.resolvedThemeMode);
const setThemeMode = usePreferencesStore((s) => s.setThemeMode);
const themePreset = usePreferencesStore((s) => s.themePreset);
const setThemePreset = usePreferencesStore((s) => s.setThemePreset);
const contentLayout = usePreferencesStore((s) => s.contentLayout);
const setContentLayout = usePreferencesStore((s) => s.setContentLayout);
const navbarStyle = usePreferencesStore((s) => s.navbarStyle);
const setNavbarStyle = usePreferencesStore((s) => s.setNavbarStyle);
const variant = usePreferencesStore((s) => s.sidebarVariant);
const setSidebarVariant = usePreferencesStore((s) => s.setSidebarVariant);
const collapsible = usePreferencesStore((s) => s.sidebarCollapsible);
const setSidebarCollapsible = usePreferencesStore((s) => s.setSidebarCollapsible);
const font = usePreferencesStore((s) => s.font);
const setFont = usePreferencesStore((s) => s.setFont);
const onThemePresetChange = (preset: ThemePreset) => {
applyThemePreset(preset);
setThemePreset(preset);
void persistPreference("theme_preset", preset);
};
const onThemeModeChange = (mode: ThemeMode | "") => {
if (!mode) return;
setThemeMode(mode);
void persistPreference("theme_mode", mode);
};
const onContentLayoutChange = (layout: ContentLayout | "") => {
if (!layout) return;
applyContentLayout(layout);
setContentLayout(layout);
void persistPreference("content_layout", layout);
};
const onNavbarStyleChange = (style: NavbarStyle | "") => {
if (!style) return;
applyNavbarStyle(style);
setNavbarStyle(style);
void persistPreference("navbar_style", style);
};
const onSidebarStyleChange = (value: SidebarVariant | "") => {
if (!value) return;
setSidebarVariant(value);
applySidebarVariant(value);
void persistPreference("sidebar_variant", value);
};
const onSidebarCollapseModeChange = (value: SidebarCollapsible | "") => {
if (!value) return;
setSidebarCollapsible(value);
applySidebarCollapsible(value);
void persistPreference("sidebar_collapsible", value);
};
const onFontChange = (value: FontKey | "") => {
if (!value) return;
applyFont(value);
setFont(value);
void persistPreference("font", value);
};
const handleRestore = () => {
onThemePresetChange(PREFERENCE_DEFAULTS.theme_preset);
onThemeModeChange(PREFERENCE_DEFAULTS.theme_mode);
onContentLayoutChange(PREFERENCE_DEFAULTS.content_layout);
onNavbarStyleChange(PREFERENCE_DEFAULTS.navbar_style);
onSidebarStyleChange(PREFERENCE_DEFAULTS.sidebar_variant);
onSidebarCollapseModeChange(PREFERENCE_DEFAULTS.sidebar_collapsible);
onFontChange(PREFERENCE_DEFAULTS.font);
};
return (
<Popover>
<PopoverTrigger asChild>
<Button size="icon">
<Settings />
</Button>
</PopoverTrigger>
<PopoverContent align="end">
<div className="flex flex-col gap-5">
<div className="space-y-1.5">
<h4 className="font-medium text-sm leading-none">Preferences</h4>
<p className="text-muted-foreground text-xs">Customize your dashboard layout preferences.</p>
</div>
<div className="space-y-3 **:data-[slot=toggle-group]:w-full **:data-[slot=toggle-group-item]:flex-1 **:data-[slot=toggle-group-item]:text-xs">
<div className="space-y-1">
<Label className="font-medium text-xs">Theme Preset</Label>
<Select value={themePreset} onValueChange={onThemePresetChange}>
<SelectTrigger size="sm" className="w-full text-xs">
<SelectValue placeholder="Preset" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{THEME_PRESET_OPTIONS.map((preset) => (
<SelectItem key={preset.value} className="text-xs" value={preset.value}>
<span
className="size-2.5 rounded-full"
style={{
backgroundColor:
(resolvedThemeMode ?? "light") === "dark" ? preset.primary.dark : preset.primary.light,
}}
/>
{preset.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="font-medium text-xs">Fonts</Label>
<Select value={font} onValueChange={onFontChange}>
<SelectTrigger size="sm" className="w-full text-xs">
<SelectValue placeholder="Select font" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{fontOptions.map((font) => (
<SelectItem key={font.key} className="text-xs" value={font.key}>
{font.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="font-medium text-xs">Theme Mode</Label>
<ToggleGroup
size="sm"
spacing={0}
variant="outline"
type="single"
value={themeMode}
onValueChange={onThemeModeChange}
>
<ToggleGroupItem value="light" aria-label="Toggle light">
Light
</ToggleGroupItem>
<ToggleGroupItem value="dark" aria-label="Toggle dark">
Dark
</ToggleGroupItem>
<ToggleGroupItem value="system" aria-label="Toggle system">
System
</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="space-y-1">
<Label className="font-medium text-xs">Page Layout</Label>
<ToggleGroup
size="sm"
spacing={0}
variant="outline"
type="single"
value={contentLayout}
onValueChange={onContentLayoutChange}
>
<ToggleGroupItem value="centered" aria-label="Toggle centered">
Centered
</ToggleGroupItem>
<ToggleGroupItem value="full-width" aria-label="Toggle full-width">
Full Width
</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="space-y-1">
<Label className="font-medium text-xs">Navbar Behavior</Label>
<ToggleGroup
size="sm"
spacing={0}
variant="outline"
type="single"
value={navbarStyle}
onValueChange={onNavbarStyleChange}
>
<ToggleGroupItem value="sticky" aria-label="Toggle sticky">
Sticky
</ToggleGroupItem>
<ToggleGroupItem value="scroll" aria-label="Toggle scroll">
Scroll
</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="space-y-1">
<Label className="font-medium text-xs">Sidebar Style</Label>
<ToggleGroup
size="sm"
spacing={0}
variant="outline"
type="single"
value={variant}
onValueChange={onSidebarStyleChange}
>
<ToggleGroupItem value="inset" aria-label="Toggle inset">
Inset
</ToggleGroupItem>
<ToggleGroupItem value="sidebar" aria-label="Toggle sidebar">
Sidebar
</ToggleGroupItem>
<ToggleGroupItem value="floating" aria-label="Toggle floating">
Floating
</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="space-y-1">
<Label className="font-medium text-xs">Sidebar Collapse Mode</Label>
<ToggleGroup
size="sm"
spacing={0}
variant="outline"
type="single"
value={collapsible}
onValueChange={onSidebarCollapseModeChange}
>
<ToggleGroupItem value="icon" aria-label="Toggle icon">
Icon
</ToggleGroupItem>
<ToggleGroupItem value="offcanvas" aria-label="Toggle offcanvas">
OffCanvas
</ToggleGroupItem>
</ToggleGroup>
</div>
<Button type="button" size="sm" variant="outline" className="w-full text-xs" onClick={handleRestore}>
Restore Defaults
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,83 @@
"use client";
import { Ellipsis, Folder, Forward, type LucideIcon, Trash2 } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
export function NavDocuments({
items,
}: {
readonly items: readonly {
readonly name: string;
readonly url: string;
readonly icon: LucideIcon;
}[];
}) {
const { isMobile } = useSidebar();
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Documents</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover className="rounded-sm data-[state=open]:bg-accent">
<Ellipsis />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-24 rounded-lg"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem>
<Folder />
<span>Open</span>
</DropdownMenuItem>
<DropdownMenuItem>
<Forward />
<span>Share</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2 />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<Ellipsis className="text-sidebar-foreground/70" />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
);
}
@@ -0,0 +1,326 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronRight, MailIcon, PlusCircleIcon } from "lucide-react";
import { api } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
useSidebar,
} from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
import type {
NavBadge,
NavGroup,
NavMainItem,
NavMainLinkItem,
NavMainParentItem,
} from "@/navigation/sidebar/sidebar-items";
interface NavMainProps {
readonly items: readonly NavGroup[];
}
interface NavItemProps {
readonly item: NavMainItem;
readonly isItemActive: (item: NavMainItem) => boolean;
readonly isSubItemActive: (url: string) => boolean;
readonly isSubmenuOpen: (item: NavMainParentItem) => boolean;
}
interface NavLinkItemProps {
readonly item: NavMainLinkItem;
readonly isActive: boolean;
readonly showIconFallback: boolean;
}
interface NavLinkIconProps {
readonly item: NavMainLinkItem;
readonly showFallback: boolean;
}
interface NavDropdownItemProps {
readonly item: NavMainParentItem;
readonly isActive: boolean;
readonly isSubItemActive: (url: string) => boolean;
}
interface NavCollapsibleItemProps {
readonly item: NavMainParentItem;
readonly isActive: boolean;
readonly defaultOpen: boolean;
readonly isSubItemActive: (url: string) => boolean;
}
function CollapsedIconFallback({ title }: { title: string }) {
return (
<span className="flex size-4 shrink-0 items-center justify-center rounded-xs font-medium text-[10px] outline">
{title.slice(0, 1)}
</span>
);
}
function hasSubItems(item: NavMainItem): item is NavMainParentItem {
return Boolean(item.subItems?.length);
}
export function NavMain({ items }: NavMainProps) {
const rawPath = usePathname();
// 路由段含中文,pathname 可能是百分号编码,解码后再与导航 url 比较,保证高亮命中。
const path = (() => {
try {
return decodeURIComponent(rawPath);
} catch {
return rawPath;
}
})();
const isItemActive = (item: NavMainItem) => {
if (hasSubItems(item)) {
return item.subItems.some((sub) => path.startsWith(sub.url));
}
return path === item.url;
};
const isSubItemActive = (url: string) => {
return path === url;
};
const isSubmenuOpen = (item: NavMainParentItem) => {
return item.subItems.some((sub) => path.startsWith(sub.url));
};
return (
<>
{items.map((group) => (
<SidebarGroup key={group.id}>
{group.label && (
<SidebarGroupLabel className="group-data-[collapsible=icon]:pointer-events-none">
{group.label}
</SidebarGroupLabel>
)}
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => (
<NavItem
key={item.id}
item={item}
isItemActive={isItemActive}
isSubItemActive={isSubItemActive}
isSubmenuOpen={isSubmenuOpen}
/>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</>
);
}
function NavItem({ item, isItemActive, isSubItemActive, isSubmenuOpen }: NavItemProps) {
const { state, isMobile } = useSidebar();
const isCollapsedDesktop = state === "collapsed" && !isMobile;
if (!hasSubItems(item)) {
return <NavLinkItem item={item} isActive={isItemActive(item)} showIconFallback={isCollapsedDesktop} />;
}
if (isCollapsedDesktop) {
return <NavDropdownItem item={item} isActive={isItemActive(item)} isSubItemActive={isSubItemActive} />;
}
return (
<NavCollapsibleItem
item={item}
isActive={isItemActive(item)}
defaultOpen={isSubmenuOpen(item)}
isSubItemActive={isSubItemActive}
/>
);
}
// InterceptPendingBadge polls /api/intercept/pending every 5 s and shows a red
// count badge when there are unresolved approval requests.
function InterceptPendingBadge() {
const [count, setCount] = React.useState(0);
React.useEffect(() => {
let live = true;
async function poll() {
try {
const list = await api.interceptPending();
if (live) setCount(list.length);
} catch { /* ignore */ }
}
poll();
const t = setInterval(poll, 5000);
return () => { live = false; clearInterval(t); };
}, []);
if (count === 0) return null;
return (
<SidebarMenuBadge className="flex h-4.5 min-w-[18px] items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-semibold leading-none text-white shadow-sm ring-1 ring-amber-400/40">
{count > 99 ? "99+" : count}
</SidebarMenuBadge>
);
}
function NavLinkItem({ item, isActive, showIconFallback }: NavLinkItemProps) {
return (
<SidebarMenuItem>
<SidebarMenuButton asChild aria-disabled={item.disabled} tooltip={item.title} isActive={isActive}>
<Link
prefetch={false}
href={item.url}
target={item.newTab ? "_blank" : undefined}
rel={item.newTab ? "noreferrer" : undefined}
>
<NavLinkIcon item={item} showFallback={showIconFallback} />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
{item.id === "approvals" ? <InterceptPendingBadge /> : <NavItemBadge badge={item.badge} />}
</SidebarMenuItem>
);
}
function NavLinkIcon({ item, showFallback }: NavLinkIconProps) {
const Icon = item.icon;
if (Icon) {
return <Icon />;
}
if (showFallback) {
return <CollapsedIconFallback title={item.title} />;
}
return null;
}
function NavDropdownItem({ item, isActive, isSubItemActive }: NavDropdownItemProps) {
const Icon = item.icon;
return (
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton tooltip={item.title} isActive={isActive} disabled={item.disabled}>
{Icon ? <Icon /> : <CollapsedIconFallback title={item.title} />}
<span>{item.title}</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" sideOffset={12} className="w-48">
<DropdownMenuGroup>
{item.subItems.map((subItem) => {
const SubIcon = subItem.icon;
return (
<DropdownMenuItem key={subItem.id} asChild disabled={subItem.disabled}>
<Link
prefetch={false}
href={subItem.url}
target={subItem.newTab ? "_blank" : undefined}
rel={subItem.newTab ? "noreferrer" : undefined}
aria-current={isSubItemActive(subItem.url) ? "page" : undefined}
className="flex items-center gap-2"
>
{SubIcon && <SubIcon />}
<span>{subItem.title}</span>
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
);
}
function NavCollapsibleItem({ item, isActive, defaultOpen, isSubItemActive }: NavCollapsibleItemProps) {
const Icon = item.icon;
return (
<Collapsible asChild defaultOpen={defaultOpen} className="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title} isActive={isActive} disabled={item.disabled}>
{Icon && <Icon />}
<span>{item.title}</span>
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<NavItemBadge badge={item.badge} />
<CollapsibleContent>
<SidebarMenuSub>
{item.subItems.map((subItem) => {
const SubIcon = subItem.icon;
return (
<SidebarMenuSubItem key={subItem.id}>
<SidebarMenuSubButton
asChild
aria-disabled={subItem.disabled}
isActive={isSubItemActive(subItem.url)}
>
<Link
prefetch={false}
href={subItem.url}
target={subItem.newTab ? "_blank" : undefined}
rel={subItem.newTab ? "noreferrer" : undefined}
>
{SubIcon && <SubIcon />}
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
);
}
function NavItemBadge({ badge }: { badge?: NavBadge }) {
if (!badge) {
return null;
}
return (
<SidebarMenuBadge
className={cn(
"rounded-sm border capitalize",
badge === "new" &&
"border-green-600 text-green-600 peer-hover/menu-button:text-green-600 peer-data-active/menu-button:text-green-600",
badge === "soon" && "border-muted-foreground text-muted-foreground",
)}
>
{badge}
</SidebarMenuBadge>
);
}
@@ -0,0 +1,43 @@
"use client";
import type * as React from "react";
import type { LucideIcon } from "lucide-react";
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
export function NavSecondary({
items,
...props
}: {
items: {
title: string;
url: string;
icon: LucideIcon;
}[];
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
@@ -0,0 +1,95 @@
"use client";
import * as React from "react";
import { EllipsisVertical, KeyRound, LogOut } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "@/components/ui/sidebar";
import { auth } from "@/lib/auth";
import { getInitials } from "@/lib/utils";
import { ChangePasswordDialog } from "./change-password-dialog";
export function NavUser({
user,
}: {
readonly user: {
readonly name: string;
readonly email: string;
readonly avatar: string;
};
}) {
const { isMobile } = useSidebar();
const [pwOpen, setPwOpen] = React.useState(false);
function handleLogout() {
auth.clearToken();
// 硬跳转:让浏览器用已清除的 cookie 发起全新请求,
// middleware 才能正确读到空 token 并放行 /login
window.location.href = "/login";
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg grayscale">
<AvatarImage src={user.avatar || undefined} alt={user.name} />
<AvatarFallback className="rounded-lg">{getInitials(user.name)}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-muted-foreground text-xs">{user.email}</span>
</div>
<EllipsisVertical className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar || undefined} alt={user.name} />
<AvatarFallback className="rounded-lg">{getInitials(user.name)}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-muted-foreground text-xs">{user.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setPwOpen(true)}>
<KeyRound />
修改密码
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
<LogOut />
退出登录
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
<ChangePasswordDialog open={pwOpen} onOpenChange={setPwOpen} />
</SidebarMenu>
);
}
@@ -0,0 +1,157 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import type { NavMainItem } from "@/navigation/sidebar/sidebar-items";
import { sidebarItems } from "@/navigation/sidebar/sidebar-items";
type SearchItem = {
id: string;
group: string;
label: string;
url: string;
icon?: NavMainItem["icon"];
disabled?: boolean;
newTab?: boolean;
};
const sidebarGroupLabels = new Set(sidebarItems.flatMap((group) => (group.label ? [group.label] : [])));
function getSubItemGroup(groupLabel: string | undefined, itemTitle: string) {
return sidebarGroupLabels.has(itemTitle) ? (groupLabel ?? "Other") : itemTitle;
}
const searchItems: SearchItem[] = sidebarItems.flatMap((group) =>
group.items.flatMap((item) => {
if (item.subItems) {
return item.subItems.map((sub) => ({
id: sub.id,
group: getSubItemGroup(group.label, item.title),
label: sub.title,
url: sub.url,
icon: item.icon,
disabled: sub.disabled,
newTab: sub.newTab,
}));
}
return [
{
id: item.id,
group: group.label ?? "Other",
label: item.title,
url: item.url,
icon: item.icon,
disabled: item.disabled,
newTab: item.newTab,
},
];
}),
);
function getAvailableItems(items: SearchItem[]) {
return items.filter((item) => !item.disabled && !item.url.includes("coming-soon"));
}
const recommendations = getAvailableItems(searchItems);
function groupBy(items: SearchItem[]) {
const groups = [...new Set(items.map((item) => item.group))];
return groups.map((group) => ({
group,
items: items.filter((item) => item.group === group),
}));
}
export function SearchDialog() {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const router = useRouter();
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((prev) => !prev);
}
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);
const handleOpenChange = (value: boolean) => {
setOpen(value);
if (!value) setQuery("");
};
const handleSelect = (item: SearchItem) => {
if (item.disabled) return;
handleOpenChange(false);
if (item.newTab) {
window.open(item.url, "_blank", "noopener,noreferrer");
} else {
router.push(item.url);
}
};
const renderGroups = (items: SearchItem[]) =>
groupBy(items).map(({ group, items: groupItems }, index) => (
<React.Fragment key={group}>
{index > 0 && <CommandSeparator />}
<CommandGroup heading={group}>
{groupItems.map((item) => (
<CommandItem
disabled={item.disabled}
key={`${group}-${item.id}`}
value={`${item.group} ${item.label}`}
onSelect={() => handleSelect(item)}
>
<span className="flex min-w-0 items-center gap-2">
{item.icon && <item.icon />}
<span className="truncate">{item.label}</span>
</span>
</CommandItem>
))}
</CommandGroup>
</React.Fragment>
));
return (
<>
<Button
onClick={() => handleOpenChange(true)}
variant="link"
className="px-0! font-normal text-muted-foreground hover:no-underline"
>
<Search data-icon="inline-start" />
Search
<kbd className="inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-medium text-[10px]">
<span className="text-xs">⌘</span>J
</kbd>
</Button>
<CommandDialog open={open} onOpenChange={handleOpenChange}>
<Command>
<CommandInput placeholder="Search dashboards, users, and more…" value={query} onValueChange={setQuery} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{query ? renderGroups(searchItems) : renderGroups(recommendations)}
</CommandList>
</Command>
</CommandDialog>
</>
);
}
@@ -0,0 +1,29 @@
import Link from "next/link";
import { siX } from "simple-icons";
import { SimpleIcon } from "@/components/simple-icon";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export function SidebarSupportCard() {
return (
<Card size="sm" className="overflow-hidden shadow-none group-data-[collapsible=icon]:hidden">
<CardHeader className="min-w-0 px-4">
<CardTitle className="truncate text-sm">Looking for something more?</CardTitle>
<CardDescription className="line-clamp-2">
Open an issue or do reach out to me on&nbsp;
<Link
href="https://x.com/arhamkhnz"
target="_blank"
rel="noreferrer"
aria-label="Reach out on X"
className="inline-flex items-center text-foreground"
>
<SimpleIcon icon={siX} aria-hidden className="size-3 fill-current" />
</Link>
.
</CardDescription>
</CardHeader>
</Card>
);
}
@@ -0,0 +1,35 @@
"use client";
import { Monitor, Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import { persistPreference } from "@/lib/preferences/preferences-storage";
import { usePreferencesStore } from "@/stores/preferences/preferences-provider";
const THEME_CYCLE = ["light", "dark", "system"] as const;
export function ThemeSwitcher() {
const themeMode = usePreferencesStore((s) => s.themeMode);
const setThemeMode = usePreferencesStore((s) => s.setThemeMode);
const cycleTheme = () => {
const currentIndex = THEME_CYCLE.indexOf(themeMode);
const nextTheme = THEME_CYCLE[(currentIndex + 1) % THEME_CYCLE.length];
setThemeMode(nextTheme);
void persistPreference("theme_mode", nextTheme);
};
return (
<Button size="icon" onClick={cycleTheme} aria-label={`Current theme: ${themeMode}. Click to cycle themes`}>
{/* SYSTEM */}
<Monitor className="hidden [html[data-theme-mode=system]_&]:block" />
{/* DARK (resolved) */}
<Sun className="hidden dark:block [html[data-theme-mode=system]_&]:hidden" />
{/* LIGHT (resolved) */}
<Moon className="block dark:hidden [html[data-theme-mode=system]_&]:hidden" />
</Button>
);
}
+740
View File
@@ -0,0 +1,740 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import { Bot, ChevronDownIcon, PlusIcon, SendIcon, Square, Trash2Icon, ZapIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Transcript } from "@/components/transcript";
import { TodoPopover } from "@/components/todo-popover";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { api } from "@/lib/api";
import type { Activity, Agent, Conversation, LLMProfile } from "@/lib/types";
import { cn } from "@/lib/utils";
// fmtTokens renders a compact token count (1234 → 1.2k, 2_000_000 → 2M).
function fmtTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M";
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k";
return String(n);
}
// fmtDuration renders an elapsed milliseconds span compactly (90s → 1m30s).
function fmtDuration(ms: number): string {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m${String(s % 60).padStart(2, "0")}s`;
const h = Math.floor(m / 60);
return `${h}h${String(m % 60).padStart(2, "0")}m`;
}
// LiveBadge is the small pulsing "实时" chip reused from the task's main-agent
// console — shown while a turn is streaming.
function LiveBadge() {
return (
<span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
<span className="size-1 animate-pulse rounded-full bg-blue-500" />
实时
</span>
);
}
// Composer is the shared bottom input (textarea grows to a cap, Enter sends,
// Shift+Enter newlines) — the same affordance across DraftChat and ChatView.
function Composer({
value,
onChange,
onSend,
disabled,
placeholder,
leftSlot,
running,
onStop,
stopDisabled,
}: {
value: string;
onChange: (v: string) => void;
onSend: () => void;
disabled: boolean;
placeholder: string;
leftSlot?: React.ReactNode;
running?: boolean;
onStop?: () => void;
stopDisabled?: boolean;
}) {
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSend();
}
}
return (
<div className="border-t p-3">
<div className="flex items-end gap-2">
{leftSlot}
<Textarea
className="max-h-40 min-h-10 flex-1 resize-none"
rows={1}
placeholder={placeholder}
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
/>
{running ? (
// while a run is in flight the send button becomes a stop button —
// aborts just this session (the trigger queue keeps going).
<Button size="icon" variant="destructive" onClick={onStop} disabled={stopDisabled} title="停止本次运行">
<Square className="size-3.5 fill-current" />
</Button>
) : (
<Button size="icon" onClick={onSend} disabled={disabled || !value.trim()}>
<SendIcon className="size-4" />
</Button>
)}
</div>
</div>
);
}
// LLMProfileRow shows the active LLM config below the composer and lets the user
// switch it via a Popover. `selected` is the profile id or null for default.
function LLMProfileRow({
profiles,
selected,
onChange,
disabled,
rightSlot,
}: {
profiles: LLMProfile[];
selected: number | null;
onChange: (id: number | null) => void;
disabled?: boolean;
rightSlot?: React.ReactNode;
}) {
const [open, setOpen] = React.useState(false);
const activeDefault = profiles.find((p) => p.is_default);
const current = selected != null ? profiles.find((p) => Number(p.id) === selected) : null;
const label = current ? current.name : `默认${activeDefault ? `(${activeDefault.name})` : ""}`;
return (
<div className="flex items-center gap-1 px-1 pb-1 pt-0.5">
<ZapIcon className="text-muted-foreground/50 size-3 shrink-0" />
<span className="text-muted-foreground/70 text-xs">{label}</span>
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
className="text-primary flex items-center gap-0.5 text-xs hover:underline disabled:pointer-events-none disabled:opacity-40"
>
更换
<ChevronDownIcon className="size-3" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-1">
<p className="text-muted-foreground px-2 py-1 text-[11px] font-medium">选择 LLM 配置</p>
{/* default option */}
<button
type="button"
onClick={() => { onChange(null); setOpen(false); }}
className={cn(
"flex w-full flex-col rounded px-2 py-1.5 text-left hover:bg-accent",
selected == null && "bg-accent",
)}
>
<span className="text-sm">默认{activeDefault ? `(${activeDefault.name})` : ""}</span>
{activeDefault && (
<span className="text-muted-foreground text-[11px]">{activeDefault.format} · {activeDefault.model}</span>
)}
</button>
{profiles.map((p) => (
<button
key={p.id}
type="button"
onClick={() => { onChange(Number(p.id)); setOpen(false); }}
className={cn(
"flex w-full flex-col rounded px-2 py-1.5 text-left hover:bg-accent",
selected === Number(p.id) && "bg-accent",
)}
>
<span className="text-sm">{p.name}</span>
<span className="text-muted-foreground text-[11px]">{p.format} · {p.model}</span>
</button>
))}
</PopoverContent>
</Popover>
{rightSlot}
</div>
);
}
// DraftChat is the default right-pane view: a fresh chat (agent picker in the
// header, centered empty state, composer) with NO conversation created yet. The
// conversation is created lazily on the first send (ChatGPT-style), then the
// parent switches to the real ChatView.
function DraftChat({
agents,
profiles,
onStarted,
}: {
agents: Agent[];
profiles: LLMProfile[];
onStarted: (c: Conversation) => void;
}) {
const [agentKey, setAgentKey] = React.useState("");
const [llmProfileId, setLlmProfileId] = React.useState<number | null>(null);
const [input, setInput] = React.useState("");
const [sending, setSending] = React.useState(false);
// default the agent to Auto once agents load.
React.useEffect(() => {
if (!agentKey && agents.some((a) => a.key === "auto")) setAgentKey("auto");
}, [agentKey, agents]);
const agent = agents.find((a) => a.key === agentKey);
async function send() {
const msg = input.trim();
if (!msg || !agentKey || sending) return;
setSending(true);
try {
const c = await api.createConversation(agentKey, "", llmProfileId);
await api.sendConversationMessage(c.id, msg);
onStarted(c);
} catch (e) {
toast.error("发送失败:" + (e as Error).message);
setSending(false);
}
}
const agentPicker = (
<Select value={agentKey} onValueChange={setAgentKey}>
<SelectTrigger size="sm" className="w-40 shrink-0">
<SelectValue placeholder="选择 Agent…" />
</SelectTrigger>
<SelectContent>
{agents.map((a) => (
<SelectItem key={a.key} value={a.key}>
<span className="flex items-center gap-2">
<Bot className="size-3.5" />
{a.name}
{!a.builtin && (
<Badge variant="outline" className="px-1 py-0 text-[9px]">自定义</Badge>
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
return (
<>
{/* empty / landing state fills the panel */}
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-2 px-4 text-center">
<div className="bg-primary/10 flex size-12 items-center justify-center rounded-full">
<Bot className="text-primary size-6" />
</div>
<div className="text-sm font-medium">开始和「{agent?.name ?? "Agent"}」对话</div>
{agent?.description && (
<p className="text-muted-foreground max-w-md text-xs">{agent.description}</p>
)}
</div>
<Composer
value={input}
onChange={setInput}
onSend={send}
disabled={sending || !agentKey}
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
leftSlot={agentPicker}
/>
<LLMProfileRow
profiles={profiles}
selected={llmProfileId}
onChange={setLlmProfileId}
disabled={sending}
/>
</>
);
}
// ChatView is the right pane for one conversation — mirrors the task detail's
// main-agent console: header with agent + live badge + token/duration meta, a
// scroll-stick transcript (chat mode), and the composer.
function ChatView({
conv,
agents,
profiles,
onTitleMaybeChanged,
onConvUpdated,
}: {
conv: Conversation;
agents: Agent[];
profiles: LLMProfile[];
onTitleMaybeChanged: () => void;
onConvUpdated: () => void;
}) {
const [messages, setMessages] = React.useState<Activity[]>([]);
const [running, setRunning] = React.useState(false);
const [input, setInput] = React.useState("");
const [sending, setSending] = React.useState(false);
const [stopping, setStopping] = React.useState(false);
const cursorRef = React.useRef(0);
const agent = agents.find((a) => a.key === conv.agent_key);
const currentProfileId = conv.llm_profile_id ?? null;
async function changeProfile(id: number | null) {
try {
await api.updateConversationProfile(conv.id, id);
onConvUpdated();
} catch (e) {
toast.error("切换 LLM 失败:" + (e as Error).message);
}
}
// conversation-scoped detail fetcher for the reused Transcript renderer.
const fetchDetail = React.useCallback(
(seq: number) => api.conversationMsgDetail(conv.id, seq),
[conv.id],
);
// seq of the most-recent TodoWrite tool call (for the Todo popover); null if none.
const latestTodoSeq = React.useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
const a = messages[i];
if (a.kind === "tool_use" && a.tool === "TodoWrite") return a.seq;
}
return null;
}, [messages]);
// reset + load whenever the selected conversation changes.
React.useEffect(() => {
cursorRef.current = 0;
setMessages([]);
setRunning(false);
let live = true;
api
.conversationMessages(conv.id, 0)
.then((r) => {
if (!live) return;
setMessages(r.items);
cursorRef.current = r.cursor;
setRunning(r.running);
})
.catch(() => {});
return () => {
live = false;
};
}, [conv.id]);
// poll while a turn is running: pull new steps after the cursor.
React.useEffect(() => {
if (!running) return;
let live = true;
const tick = async () => {
try {
const r = await api.conversationMessages(conv.id, cursorRef.current);
if (!live) return;
if (r.items.length) {
setMessages((prev) => [...prev, ...r.items]);
cursorRef.current = r.cursor;
}
setRunning(r.running);
if (!r.running) onTitleMaybeChanged(); // first-turn auto-title landed
} catch {
/* transient — keep polling */
}
};
const h = setInterval(tick, 1000);
return () => {
live = false;
clearInterval(h);
};
}, [running, conv.id, onTitleMaybeChanged]);
// ---- transcript auto-scroll (open → bottom; stick to bottom unless scrolled up) ----
const contentRef = React.useRef<HTMLDivElement | null>(null);
const atBottomRef = React.useRef(true);
const viewport = React.useCallback(
() =>
(contentRef.current?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null) ??
null,
[],
);
React.useEffect(() => {
const vp = viewport();
if (!vp) return;
const onScroll = () => {
atBottomRef.current = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - 60;
};
vp.addEventListener("scroll", onScroll, { passive: true });
return () => vp.removeEventListener("scroll", onScroll);
}, [viewport]);
// open/switch a conversation → jump to the latest (bottom)
React.useLayoutEffect(() => {
const vp = viewport();
if (vp) {
vp.scrollTop = vp.scrollHeight;
atBottomRef.current = true;
}
}, [conv.id, viewport]);
// new activity → stick to bottom only if the user is already pinned there
React.useLayoutEffect(() => {
if (!atBottomRef.current) return;
const vp = viewport();
if (vp) vp.scrollTop = vp.scrollHeight;
}, [messages, running, viewport]);
// Per-conversation token total, live — same accounting as the main-agent
// console: completed runs' `result` sum + the in-progress run's latest `usage`.
const tokenTotal = React.useMemo(() => {
let i = 0, o = 0, cr = 0;
let li = 0, lo = 0, lcr = 0;
let turns = 0; // agent 循环轮次 = 模型调用次数(每次一条 kind='usage')
for (const a of messages) {
if (a.kind === "result") {
i += a.input_tokens ?? 0;
o += a.output_tokens ?? 0;
cr += a.cache_read_tokens ?? 0;
li = lo = lcr = 0;
} else if (a.kind === "usage") {
turns += 1;
li = a.input_tokens ?? 0;
lo = a.output_tokens ?? 0;
lcr = a.cache_read_tokens ?? 0;
}
}
const I = i + li, O = o + lo, CR = cr + lcr;
return { i: I, o: O, cr: CR, turns, any: I + O + CR > 0 };
}, [messages]);
async function send() {
const msg = input.trim();
if (!msg || sending || running) return;
setSending(true);
setInput("");
try {
await api.sendConversationMessage(conv.id, msg);
setRunning(true);
// pull the just-persisted human turn immediately.
const r = await api.conversationMessages(conv.id, cursorRef.current);
setMessages((prev) => [...prev, ...r.items]);
cursorRef.current = r.cursor;
} catch (e) {
toast.error("发送失败:" + (e as Error).message);
setInput(msg); // restore so the user doesn't lose their text
} finally {
setSending(false);
}
}
// stop aborts the in-flight run for this conversation. running flips to false on
// the next 1s poll once the backend unwinds the agent; the trigger queue is not
// affected — the agent's next queued fire still starts.
async function stop() {
if (stopping) return;
setStopping(true);
try {
await api.stopConversation(conv.id);
} catch (e) {
toast.error("停止失败:" + (e as Error).message);
} finally {
setStopping(false);
}
}
return (
<>
{/* header: which agent + live + token meta */}
<div className="flex items-center gap-2 border-b px-4 py-2.5">
<Bot className="text-muted-foreground size-4 shrink-0" />
<span className="shrink-0 text-sm font-medium">{agent?.name ?? conv.agent_key}</span>
<span className="text-muted-foreground shrink-0 font-mono text-xs">{conv.agent_key}</span>
{agent && !agent.builtin && (
<Badge variant="outline" className="shrink-0 px-1.5 py-0 text-[10px]">
自定义
</Badge>
)}
{agent?.description && (
<span className="text-muted-foreground min-w-0 truncate text-xs">{agent.description}</span>
)}
{running && <LiveBadge />}
<div className="text-muted-foreground ml-auto flex shrink-0 items-center gap-3 text-xs">
{tokenTotal.turns > 0 && (
<span title="agent 循环轮次(模型调用次数)" className="tabular-nums">
{tokenTotal.turns} 轮
</span>
)}
{tokenTotal.any && (
<span title="input / cache(read) / output tokens" className="tabular-nums">
input {fmtTokens(tokenTotal.i)} · cache {fmtTokens(tokenTotal.cr)} · output {fmtTokens(tokenTotal.o)}
</span>
)}
</div>
</div>
{/* messages */}
<ScrollArea
type="auto"
className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!"
>
<div className="min-w-0 max-w-full px-4 py-3" ref={contentRef}>
{messages.length === 0 && !running ? (
<div className="text-muted-foreground py-10 text-center text-sm">
开始和「{agent?.name ?? conv.agent_key}」对话
</div>
) : (
<Transcript activity={messages} live={running} chat fetchDetail={fetchDetail} />
)}
</div>
</ScrollArea>
<Composer
value={input}
onChange={setInput}
onSend={send}
disabled={running}
placeholder={running ? "Agent 正在回复…" : "输入消息,Enter 发送,Shift+Enter 换行"}
running={running}
onStop={stop}
stopDisabled={stopping}
/>
<LLMProfileRow
profiles={profiles}
selected={currentProfileId}
onChange={changeProfile}
disabled={running || sending}
rightSlot={<TodoPopover seq={latestTodoSeq} fetchDetail={fetchDetail} />}
/>
</>
);
}
// ConversationItem is one row in the left rail — status-ish icon, title + agent
// subtitle, inline rename, hover delete. Modeled on the console's SessionItem.
function ConversationItem({
conv,
agent,
active,
renaming,
renameText,
onSelect,
onStartRename,
onRenameText,
onCommitRename,
onDelete,
}: {
conv: Conversation;
agent?: Agent;
active: boolean;
renaming: boolean;
renameText: string;
onSelect: () => void;
onStartRename: () => void;
onRenameText: (v: string) => void;
onCommitRename: () => void;
onDelete: () => void;
}) {
return (
<div
className={cn(
"group flex items-center gap-1 rounded-md pr-1 transition-colors",
active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
)}
>
{renaming ? (
<input
autoFocus
value={renameText}
onChange={(e) => onRenameText(e.target.value)}
onBlur={onCommitRename}
onKeyDown={(e) => {
if (e.key === "Enter") onCommitRename();
if (e.key === "Escape") onCommitRename();
}}
className="border-input bg-background min-w-0 flex-1 rounded-md border px-2 py-1 text-sm"
/>
) : (
<button
type="button"
onClick={onSelect}
onDoubleClick={onStartRename}
title="双击重命名"
className="min-w-0 flex-1 rounded-md px-2 py-1.5 text-left"
>
<div className="truncate text-sm">{conv.title || "新对话"}</div>
<div className="text-muted-foreground flex items-center gap-1 truncate text-[11px]">
<Bot className="size-3 shrink-0" />
{agent?.name ?? conv.agent_key}
<span className="shrink-0">·</span>
<span className="shrink-0">{new Date(conv.created_at).toLocaleDateString("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
<span className="shrink-0 opacity-60">#{conv.id}</span>
</div>
</button>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-destructive shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
>
<Trash2Icon className="size-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除对话「{conv.title || "新对话"}」?</AlertDialogTitle>
<AlertDialogDescription>此操作不可撤销。</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={onDelete}>删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
export default function ChatPage() {
const [agents, setAgents] = React.useState<Agent[]>([]);
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
const [convs, setConvs] = React.useState<Conversation[]>([]);
const [selectedId, setSelectedId] = React.useState<number | null>(null);
const [renamingId, setRenamingId] = React.useState<number | null>(null);
const [renameText, setRenameText] = React.useState("");
const reloadConvs = React.useCallback(() => {
api.conversations().then(setConvs).catch(() => setConvs([]));
}, []);
React.useEffect(() => {
api.agents().then(setAgents).catch(() => {});
api.llmProfiles().then(setProfiles).catch(() => {});
reloadConvs();
}, [reloadConvs]);
const selected = convs.find((c) => c.id === selectedId) ?? null;
// conversation agents: custom agents + conversational built-ins (role=assistant,
// e.g. Auto / 渗透测试). The orchestration built-ins (goals/planner/mainagent/worker)
// are task-specific and stay hidden from the chat page.
const chatAgents = agents.filter((a) => !a.builtin || a.role === "assistant");
async function del(id: number) {
try {
await api.deleteConversation(id);
if (selectedId === id) setSelectedId(null);
reloadConvs();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}
function startRename(c: Conversation) {
setRenamingId(c.id);
setRenameText(c.title || "");
}
async function commitRename() {
const id = renamingId;
const title = renameText.trim();
setRenamingId(null);
if (!id || !title) return;
try {
await api.renameConversation(id, title);
reloadConvs();
} catch (e) {
toast.error("重命名失败:" + (e as Error).message);
}
}
return (
<div
data-content-padding="false"
className="flex h-[calc(100svh-3rem)] flex-col overflow-hidden p-4 md:h-[calc(100svh-4rem)] md:p-6"
>
<div className="grid min-h-0 flex-1 grid-cols-[18rem_1fr] grid-rows-[minmax(0,1fr)] gap-4">
{/* left: conversation list */}
<div className="bg-card flex flex-col overflow-hidden rounded-lg border">
<div className="border-b p-2">
<Button size="sm" className="w-full" onClick={() => setSelectedId(null)}>
<PlusIcon /> 新建对话
</Button>
</div>
<ScrollArea type="auto" className="min-h-0 flex-1">
<div className="flex flex-col gap-0.5 p-2">
{convs.length === 0 && (
<p className="text-muted-foreground px-2 py-6 text-center text-xs">暂无对话</p>
)}
{convs.map((c) => (
<ConversationItem
key={c.id}
conv={c}
agent={agents.find((a) => a.key === c.agent_key)}
active={selectedId === c.id}
renaming={renamingId === c.id}
renameText={renameText}
onSelect={() => setSelectedId(c.id)}
onStartRename={() => startRename(c)}
onRenameText={setRenameText}
onCommitRename={commitRename}
onDelete={() => del(c.id)}
/>
))}
</div>
</ScrollArea>
</div>
{/* right: chat view */}
<div className="bg-card flex min-w-0 flex-col overflow-hidden rounded-lg border">
{selected ? (
<ChatView
key={selected.id}
conv={selected}
agents={agents}
profiles={profiles}
onTitleMaybeChanged={reloadConvs}
onConvUpdated={reloadConvs}
/>
) : (
<DraftChat
agents={chatAgents}
profiles={profiles}
onStarted={(c) => {
reloadConvs();
setSelectedId(c.id);
}}
/>
)}
</div>
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,306 @@
"use client";
import * as React from "react";
import Link from "next/link";
import {
ChevronRightIcon,
ShieldAlertIcon,
ArrowUpRightIcon,
} from "lucide-react";
import { StatusBadge } from "@/components/status-badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TablePagination } from "@/components/table-pagination";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
import type { Finding, Severity } from "@/lib/types";
const SEVERITY_ORDER: Record<Severity, number> = { high: 3, medium: 2, low: 1 };
function fmtTime(ts: string) {
return new Date(ts).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
export default function FindingsPage() {
const [severity, setSeverity] = React.useState<"all" | Severity>("all");
const [vulnclass, setVulnclass] = React.useState<string>("all");
const [sort, setSort] = React.useState<"severity" | "time">("severity");
const [expanded, setExpanded] = React.useState<string | null>(null);
const [findings, setFindings] = React.useState<Finding[]>([]);
const [page, setPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(20);
React.useEffect(() => {
let alive = true;
const load = () => {
api
.findings()
.then((fs) => {
if (alive) setFindings(fs);
})
.catch(() => {});
};
load();
const t = setInterval(load, 5000);
return () => {
alive = false;
clearInterval(t);
};
}, []);
const vulnclasses = React.useMemo(
() => Array.from(new Set(findings.map((f) => f.vulnclass))).sort(),
[findings],
);
const counts = React.useMemo(() => {
const c = { high: 0, medium: 0, low: 0 };
for (const f of findings) c[f.severity]++;
const tasks = new Set(findings.map((f) => f.task_id)).size;
return { ...c, total: findings.length, tasks };
}, [findings]);
const rows = React.useMemo(() => {
let list = findings.slice();
if (severity !== "all") list = list.filter((f) => f.severity === severity);
if (vulnclass !== "all")
list = list.filter((f) => f.vulnclass === vulnclass);
list.sort((a, b) => {
if (sort === "severity") {
const d = SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity];
if (d !== 0) return d;
}
return new Date(b.ts).getTime() - new Date(a.ts).getTime();
});
return list;
}, [findings, severity, vulnclass, sort]);
// reset to page 1 whenever filters change
React.useEffect(() => { setPage(1); }, [severity, vulnclass, sort]);
const paginated = React.useMemo(
() => rows.slice((page - 1) * pageSize, page * pageSize),
[rows, page, pageSize],
);
const statCards: { label: string; value: number; tone?: string }[] = [
{ label: "发现总数", value: counts.total },
{ label: "高危", value: counts.high, tone: "text-red-500" },
{ label: "中危", value: counts.medium, tone: "text-amber-500" },
{ label: "低危", value: counts.low, tone: "text-slate-500" },
{ label: "涉及任务", value: counts.tasks },
];
return (
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div>
<h1 className="text-xl font-semibold tracking-tight">发现</h1>
<p className="text-muted-foreground text-sm">跨任务漏洞汇总</p>
</div>
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
{statCards.map((s) => (
<Card key={s.label} className="gap-1 py-4">
<CardHeader className="px-4">
<CardDescription>{s.label}</CardDescription>
<CardTitle
className={cn("text-2xl tabular-nums", s.tone)}
>
{s.value}
</CardTitle>
</CardHeader>
</Card>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1 rounded-md border p-0.5">
{(
[
["all", "全部"],
["high", "高危"],
["medium", "中危"],
["low", "低危"],
] as const
).map(([val, label]) => (
<button
key={val}
onClick={() => setSeverity(val)}
className={cn(
"rounded px-2.5 py-1 text-xs font-medium transition-colors",
severity === val
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted",
)}
>
{label}
</button>
))}
</div>
<Select value={vulnclass} onValueChange={setVulnclass}>
<SelectTrigger size="sm" className="w-40">
<SelectValue placeholder="漏洞类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部类型</SelectItem>
{vulnclasses.map((vc) => (
<SelectItem key={vc} value={vc}>
{vc}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={sort}
onValueChange={(v) => setSort(v as "severity" | "time")}
>
<SelectTrigger size="sm" className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="severity">按严重度</SelectItem>
<SelectItem value="time">按时间</SelectItem>
</SelectContent>
</Select>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
共 {rows.length} 条
</span>
</div>
<Card className="py-0">
<CardContent className="px-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8" />
<TableHead className="w-20">严重度</TableHead>
<TableHead className="w-28">漏洞类型</TableHead>
<TableHead>摘要</TableHead>
<TableHead className="w-36 max-w-[9rem]">所属任务</TableHead>
<TableHead className="w-32">时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginated.map((f) => {
const open = expanded === f.id;
return (
<React.Fragment key={f.id}>
<TableRow
className="cursor-pointer"
onClick={() => setExpanded(open ? null : f.id)}
>
<TableCell>
<ChevronRightIcon
className={cn(
"size-4 text-muted-foreground transition-transform",
open && "rotate-90",
)}
/>
</TableCell>
<TableCell>
<StatusBadge domain="severity" value={f.severity} dot />
</TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
{f.vulnclass}
</code>
</TableCell>
<TableCell className="max-w-md">
<span className="line-clamp-1">{f.summary}</span>
</TableCell>
<TableCell className="w-36 max-w-[9rem]">
{f.task_id ? (
<Link
href={`/function/tasks/detail?id=${f.task_id}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex max-w-full items-center gap-1 text-primary hover:underline"
title={f.task_description}
>
<span className="truncate">{f.task_description}</span>
<ArrowUpRightIcon className="size-3 shrink-0" />
</Link>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground tabular-nums">
{fmtTime(f.ts)}
</TableCell>
</TableRow>
{open && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={6} className="bg-muted/30">
<div className="flex flex-col gap-2 px-2 py-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<ShieldAlertIcon className="size-3.5" />
证据 · {f.id}
{f.param_id && (
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">
{f.param_id}
</code>
)}
</div>
<pre className="overflow-x-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap">
{f.evidence}
</pre>
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
{rows.length === 0 && (
<TableRow>
<TableCell
colSpan={6}
className="py-12 text-center text-sm text-muted-foreground"
>
没有匹配的发现。
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<TablePagination
page={page}
pageSize={pageSize}
total={rows.length}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
</CardContent>
</Card>
</div>
</div>
);
}
+486
View File
@@ -0,0 +1,486 @@
"use client";
import * as React from "react";
import { AlertCircleIcon, CheckCircle2Icon, DownloadIcon, PlugZapIcon, RefreshCwIcon, SearchIcon } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api";
import type { SSProject, SSTask } from "@/lib/types";
type SSStatus = {
exists: boolean;
configured: boolean;
enabled: boolean;
reachable: boolean;
url?: string;
tools: string[];
};
type Dimension = "project" | "task";
const ASSET_TYPES: { key: string; label: string }[] = [
{ key: "subdomain", label: "子域名" },
{ key: "service", label: "服务" },
{ key: "app", label: "App" },
];
export default function AssetSyncPage() {
return (
<div className="p-4 md:p-6">
<div className="mb-4">
<h1 className="font-semibold text-xl">资产同步</h1>
<p className="text-muted-foreground text-sm">从外部数据源同步资产入库</p>
</div>
<Tabs defaultValue="scopesentry">
<TabsList>
<TabsTrigger value="scopesentry">ScopeSentry</TabsTrigger>
</TabsList>
<TabsContent value="scopesentry" className="mt-4">
<ScopeSentryPanel />
</TabsContent>
</Tabs>
</div>
);
}
function ScopeSentryPanel() {
const [status, setStatus] = React.useState<SSStatus | null>(null);
const [loadingStatus, setLoadingStatus] = React.useState(true);
const loadStatus = React.useCallback(() => {
setLoadingStatus(true);
api
.ssStatus()
.then(setStatus)
.catch((e) => toast.error(`读取数据源状态失败:${e.message}`))
.finally(() => setLoadingStatus(false));
}, []);
React.useEffect(() => {
loadStatus();
}, [loadStatus]);
const ready = !!status && status.exists && status.configured && status.enabled;
return (
<div className="space-y-4">
<DataSourceCard status={status} loading={loadingStatus} onChanged={loadStatus} />
{ready ? (
<SyncWorkbench />
) : (
<Card>
<CardContent className="py-8 text-center text-muted-foreground text-sm">
数据源就绪后即可选择项目 / 任务进行同步。
</CardContent>
</Card>
)}
</div>
);
}
// ── 数据源状态卡 ─────────────────────────────────────────────────────────────
function DataSourceCard({
status,
loading,
onChanged,
}: {
status: SSStatus | null;
loading: boolean;
onChanged: () => void;
}) {
const [url, setUrl] = React.useState("");
const [apiKey, setApiKey] = React.useState("");
const [busy, setBusy] = React.useState(false);
React.useEffect(() => {
if (status?.url) setUrl(status.url);
}, [status?.url]);
const create = async () => {
setBusy(true);
try {
await api.ssDatasource({});
toast.success("已创建 ScopeSentry 数据源,请填写地址与密钥");
onChanged();
} catch (e) {
toast.error(`创建失败:${(e as Error).message}`);
} finally {
setBusy(false);
}
};
const save = async () => {
if (!url.trim()) return toast.error("请填写 MCP 地址");
setBusy(true);
try {
const r = await api.ssDatasource({ url: url.trim(), api_key: apiKey.trim() });
toast.success(r.enabled ? "已保存并启用数据源" : "已保存(尚未满足启用条件)");
setApiKey("");
onChanged();
} catch (e) {
toast.error(`保存失败:${(e as Error).message}`);
} finally {
setBusy(false);
}
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<PlugZapIcon className="size-4" /> 数据源状态
<StatusBadge status={status} loading={loading} />
</CardTitle>
<Button variant="ghost" size="sm" onClick={onChanged} disabled={loading}>
<RefreshCwIcon className={loading ? "size-4 animate-spin" : "size-4"} /> 刷新
</Button>
</CardHeader>
<CardContent className="space-y-3">
{!status?.exists ? (
<div className="flex items-center justify-between gap-4">
<p className="text-muted-foreground text-sm">
尚未创建 ScopeSentry 数据源。创建后会新增一个占位 MCP(地址/密钥为空、未启用)。
</p>
<Button onClick={create} disabled={busy}>
创建数据源
</Button>
</div>
) : (
<>
{!status.configured && (
<p className="text-amber-600 text-sm dark:text-amber-500">
数据源已创建但未配置,请填写 MCP 地址与 API Key 后启用。
</p>
)}
{status.configured && !status.enabled && (
<p className="text-amber-600 text-sm dark:text-amber-500">数据源已配置但未启用,保存后将自动启用。</p>
)}
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label>MCP 地址</Label>
<Input placeholder="http://<主机>:8082/mcp" value={url} onChange={(e) => setUrl(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>API Key(X-API-Key,留空保留原值)</Label>
<Input
type="password"
placeholder="ssk_..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
</div>
</div>
<div className="flex items-center gap-2">
<Button onClick={save} disabled={busy}>
保存并启用
</Button>
{status.enabled && status.tools.length > 0 && (
<span className="text-muted-foreground text-xs">已发现 {status.tools.length} 个工具</span>
)}
</div>
</>
)}
</CardContent>
</Card>
);
}
function StatusBadge({ status, loading }: { status: SSStatus | null; loading: boolean }) {
if (loading || !status) return <Badge variant="secondary">检测中…</Badge>;
if (!status.exists) return <Badge variant="destructive">未创建</Badge>;
if (!status.configured) return <Badge variant="outline">未配置</Badge>;
if (!status.enabled) return <Badge variant="outline">未启用</Badge>;
if (status.reachable)
return (
<Badge className="bg-emerald-600 hover:bg-emerald-600">
<CheckCircle2Icon className="mr-1 size-3" /> 已连接
</Badge>
);
return (
<Badge variant="destructive">
<AlertCircleIcon className="mr-1 size-3" /> 不可达
</Badge>
);
}
// ── 同步工作区(项目 / 任务维度)────────────────────────────────────────────────
function SyncWorkbench() {
const [dimension, setDimension] = React.useState<Dimension>("project");
const [assetTypes, setAssetTypes] = React.useState<Record<string, boolean>>({
subdomain: true,
service: true,
app: true,
});
const [createCompany, setCreateCompany] = React.useState(true);
const [projects, setProjects] = React.useState<SSProject[]>([]);
const [tasks, setTasks] = React.useState<SSTask[]>([]);
const [loading, setLoading] = React.useState(false);
const [search, setSearch] = React.useState("");
const [page, setPage] = React.useState(1);
const [selected, setSelected] = React.useState<Set<string>>(new Set());
const [syncing, setSyncing] = React.useState(false);
const [result, setResult] = React.useState<Awaited<ReturnType<typeof api.ssSync>> | null>(null);
const load = React.useCallback(() => {
setLoading(true);
setSelected(new Set());
const fn =
dimension === "project"
? api.ssProjects(page, 50, search).then((r) => setProjects(r.projects))
: api.ssTasks(page, 50, search).then(setTasks);
fn.catch((e) => toast.error(`加载列表失败:${e.message}`)).finally(() => setLoading(false));
}, [dimension, page, search]);
React.useEffect(() => {
load();
}, [load]);
const rows = dimension === "project" ? projects : tasks;
const idOf = (row: SSProject | SSTask) => (dimension === "project" ? (row as SSProject).id : (row as SSTask).name);
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleAll = () => {
setSelected((prev) => (prev.size === rows.length ? new Set() : new Set(rows.map(idOf))));
};
const chosenTypes = ASSET_TYPES.filter((t) => assetTypes[t.key]).map((t) => t.key);
const runSync = async () => {
if (selected.size === 0) return toast.error(`请至少选择一个${dimension === "project" ? "项目" : "任务"}`);
if (chosenTypes.length === 0) return toast.error("请至少选择一种资产类型");
setSyncing(true);
setResult(null);
try {
const r = await api.ssSync({
dimension,
targets: [...selected],
asset_types: chosenTypes,
create_company: dimension === "project" ? createCompany : false,
});
setResult(r);
const total = Object.values(r.synced ?? {}).reduce((a, b) => a + b, 0);
toast.success(`同步完成,共入库 ${total} 条资产`);
} catch (e) {
toast.error(`同步失败:${(e as Error).message}`);
} finally {
setSyncing(false);
}
};
const renderRows = () => {
if (loading) {
return (
<TableRow>
<TableCell colSpan={4} className="py-8 text-center text-muted-foreground text-sm">
加载中…
</TableCell>
</TableRow>
);
}
if (rows.length === 0) {
return (
<TableRow>
<TableCell colSpan={4} className="py-8 text-center text-muted-foreground text-sm">
无数据
</TableCell>
</TableRow>
);
}
if (dimension === "project") {
return projects.map((p) => (
<TableRow key={p.id} className="cursor-pointer" onClick={() => toggle(p.id)}>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox checked={selected.has(p.id)} onCheckedChange={() => toggle(p.id)} />
</TableCell>
<TableCell className="font-medium">{p.name}</TableCell>
<TableCell>{p.tag ? <Badge variant="secondary">{p.tag}</Badge> : "—"}</TableCell>
<TableCell className="text-right">{p.AssetCount ?? 0}</TableCell>
</TableRow>
));
}
return tasks.map((t) => (
<TableRow key={t.id} className="cursor-pointer" onClick={() => toggle(t.name)}>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox checked={selected.has(t.name)} onCheckedChange={() => toggle(t.name)} />
</TableCell>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell>
<Badge variant={t.progress === 100 ? "secondary" : "outline"}>
{t.progress != null ? `${t.progress}%` : "—"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-xs">{t.endTime || t.creatTime || "—"}</TableCell>
</TableRow>
));
};
return (
<Card>
<CardHeader>
<CardTitle className="text-base">选择数据同步</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 维度切换 */}
<Tabs
value={dimension}
onValueChange={(v) => {
setDimension(v as Dimension);
setPage(1);
}}
>
<TabsList>
<TabsTrigger value="project">项目维度</TabsTrigger>
<TabsTrigger value="task">任务维度</TabsTrigger>
</TabsList>
</Tabs>
{/* 资产类型 + 选项 */}
<div className="flex flex-wrap items-center gap-4">
<span className="font-medium text-sm">同步资产:</span>
{ASSET_TYPES.map((t) => (
<label key={t.key} htmlFor={`at-${t.key}`} className="flex items-center gap-1.5 text-sm">
<Checkbox
id={`at-${t.key}`}
checked={!!assetTypes[t.key]}
onCheckedChange={(c) => setAssetTypes((prev) => ({ ...prev, [t.key]: !!c }))}
/>
{t.label}
</label>
))}
{dimension === "project" && (
<label htmlFor="create-company" className="flex items-center gap-1.5 text-sm">
<Checkbox id="create-company" checked={createCompany} onCheckedChange={(c) => setCreateCompany(!!c)} />
按项目建立企业并写入资产范围
</label>
)}
</div>
{/* 搜索 + 操作 */}
<div className="flex items-center gap-2">
<div className="relative max-w-xs flex-1">
<SearchIcon className="absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-8"
placeholder={dimension === "project" ? "搜索项目名" : "搜索任务名"}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
setPage(1);
load();
}
}}
/>
</div>
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
<RefreshCwIcon className={loading ? "size-4 animate-spin" : "size-4"} />
</Button>
<div className="flex-1" />
<span className="text-muted-foreground text-xs">已选 {selected.size}</span>
<Button onClick={runSync} disabled={syncing || selected.size === 0}>
<DownloadIcon className={syncing ? "size-4 animate-pulse" : "size-4"} /> 同步选中
</Button>
</div>
{/* 列表 */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox checked={rows.length > 0 && selected.size === rows.length} onCheckedChange={toggleAll} />
</TableHead>
<TableHead>{dimension === "project" ? "项目名" : "任务名"}</TableHead>
{dimension === "project" ? (
<>
<TableHead>标签</TableHead>
<TableHead className="text-right">资产数</TableHead>
</>
) : (
<>
<TableHead>状态</TableHead>
<TableHead>时间</TableHead>
</>
)}
</TableRow>
</TableHeader>
<TableBody>{renderRows()}</TableBody>
</Table>
</div>
{/* 分页 */}
<div className="flex items-center justify-end gap-2">
<Button variant="outline" size="sm" disabled={page <= 1 || loading} onClick={() => setPage((p) => p - 1)}>
上一页
</Button>
<span className="text-muted-foreground text-xs">第 {page} 页</span>
<Button
variant="outline"
size="sm"
disabled={rows.length < 50 || loading}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
{/* 结果 */}
{result && <SyncResult result={result} />}
</CardContent>
</Card>
);
}
function SyncResult({ result }: { result: Awaited<ReturnType<typeof api.ssSync>> }) {
const synced = result.synced ?? {};
const labels: Record<string, string> = { subdomain: "子域名", service: "服务", app: "App", ip: "IP" };
return (
<div className="space-y-2 rounded-md border bg-muted/40 p-3 text-sm">
<div className="flex flex-wrap gap-3">
{Object.entries(synced).map(([k, v]) => (
<Badge key={k} variant="secondary">
{labels[k] ?? k}: {v}
</Badge>
))}
</div>
{result.companies && result.companies.length > 0 && (
<p className="text-muted-foreground">新建/更新企业:{result.companies.join("、")}</p>
)}
{result.warnings && result.warnings.length > 0 && (
<ul className="list-inside list-disc text-amber-600 dark:text-amber-500">
{result.warnings.map((wm) => (
<li key={wm}>{wm}</li>
))}
</ul>
)}
{result.errors && result.errors.length > 0 && (
<ul className="list-inside list-disc text-destructive">
{result.errors.slice(0, 20).map((em) => (
<li key={em}>{em}</li>
))}
{result.errors.length > 20 && <li>…共 {result.errors.length} 条错误</li>}
</ul>
)}
</div>
);
}
@@ -0,0 +1,355 @@
"use client";
import * as React from "react";
import {
GlobeIcon,
NetworkIcon,
LayoutTemplateIcon,
LinkIcon,
KeyRoundIcon,
ChevronLeftIcon,
ChevronRightIcon,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/lib/api";
import type { Asset } from "@/lib/types";
const PAGE_SIZES = [25, 50, 100, 200];
const METHOD_COLOR: Record<string, string> = {
GET: "bg-emerald-100 text-emerald-700",
POST: "bg-blue-100 text-blue-700",
PUT: "bg-amber-100 text-amber-700",
PATCH: "bg-orange-100 text-orange-700",
DELETE: "bg-red-100 text-red-700",
HEAD: "bg-purple-100 text-purple-700",
OPTIONS: "bg-slate-100 text-slate-600",
};
function MethodBadge({ method }: { method: string }) {
const m = method.toUpperCase();
return (
<span className={cn("inline-block rounded px-1.5 py-0.5 font-mono text-[10px] font-semibold leading-none", METHOD_COLOR[m] ?? "bg-muted text-muted-foreground")}>
{m || "—"}
</span>
);
}
function statusTone(code: number) {
if (code >= 500) return "text-red-500";
if (code >= 400) return "text-amber-500";
if (code >= 300) return "text-blue-500";
if (code >= 200) return "text-emerald-500";
return "text-muted-foreground";
}
function fmtBytes(n?: number | null) {
if (!n || !Number.isFinite(n) || n <= 0) return "—";
const u = ["B", "KB", "MB"];
const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), u.length - 1);
return `${i === 0 ? n : (n / 1024 ** i).toFixed(1)} ${u[i]}`;
}
function Chips({ items, mono }: { items: string[]; mono?: boolean }) {
const clean = items.filter(Boolean);
if (clean.length === 0) return <span className="text-xs text-muted-foreground">—</span>;
return (
<div className="flex flex-wrap gap-1">
{clean.map((s, i) => (
<Badge key={i} variant="outline" className={cn("text-[10px]", mono && "font-mono")}>{s}</Badge>
))}
</div>
);
}
function AssetCard({
cols, total, page, size, onSize, onPage, children,
}: {
cols: string[];
total: number;
page: number;
size: number;
onSize: (v: number) => void;
onPage: (fn: (p: number) => number) => void;
children: React.ReactNode;
}) {
const rows = React.Children.toArray(children);
const pageCount = Math.max(1, Math.ceil(total / size));
const start = total === 0 ? 0 : page * size + 1;
const end = page * size + rows.length;
return (
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
<div className="min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent">
<Table>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow>
{cols.map((c) => <TableHead key={c}>{c}</TableHead>)}
</TableRow>
</TableHeader>
<TableBody>
{rows.length > 0 ? rows : (
<TableRow>
<TableCell colSpan={cols.length} className="py-12 text-center text-sm text-muted-foreground">
暂无数据
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{total > 0 && (
<div className="flex shrink-0 items-center gap-2 border-t px-3 py-1.5 text-xs text-muted-foreground">
<Select value={String(size)} onValueChange={(v) => onSize(Number(v))}>
<SelectTrigger size="sm" className="h-7 w-24"><SelectValue /></SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((n) => <SelectItem key={n} value={String(n)}>{n} / 页</SelectItem>)}
</SelectContent>
</Select>
<span className="tabular-nums">{start}–{end} / {total}</span>
{pageCount > 1 && (
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" className="size-7" disabled={page <= 0} onClick={() => onPage((p) => Math.max(0, p - 1))}>
<ChevronLeftIcon />
</Button>
<span className="tabular-nums">{page + 1} / {pageCount}</span>
<Button variant="outline" size="icon" className="size-7" disabled={page + 1 >= pageCount} onClick={() => onPage((p) => Math.min(pageCount - 1, p + 1))}>
<ChevronRightIcon />
</Button>
</div>
)}
</div>
)}
</Card>
);
}
const TABS: { key: string; label: string; icon: LucideIcon }[] = [
{ key: "root_domain", label: "根域名", icon: GlobeIcon },
{ key: "ip", label: "IP", icon: NetworkIcon },
{ key: "subdomain", label: "子域名", icon: GlobeIcon },
{ key: "service", label: "服务", icon: LayoutTemplateIcon },
{ key: "endpoint", label: "接口", icon: LinkIcon },
];
export function AssetsTab({ taskId }: { taskId: string }) {
const [assets, setAssets] = React.useState<Asset[]>([]);
const [loaded, setLoaded] = React.useState(false);
const [tab, setTab] = React.useState("root_domain");
const [page, setPage] = React.useState(0);
const [size, setSize] = React.useState(50);
React.useEffect(() => setPage(0), [tab, size]);
React.useEffect(() => {
let alive = true;
const load = async () => {
try {
const results = await Promise.all(
TABS.map((t) => api.taskAssets(taskId, t.key)),
);
if (!alive) return;
setAssets(results.flat());
setLoaded(true);
} catch {
if (alive) setLoaded(true);
}
};
load();
const timer = setInterval(load, 10_000);
return () => { alive = false; clearInterval(timer); };
}, [taskId]);
const byType = React.useMemo(() => {
const m: Record<string, Asset[]> = { root_domain: [], ip: [], subdomain: [], service: [], endpoint: [] };
for (const a of assets) {
if (a.type in m) m[a.type].push(a);
}
return m;
}, [assets]);
const counts: Record<string, number> = {
root_domain: byType.root_domain.length,
ip: byType.ip.length,
subdomain: byType.subdomain.length,
service: byType.service.length,
endpoint: byType.endpoint.length,
};
const totalAll = assets.length;
if (!loaded) {
return <div className="py-20 text-center text-sm text-muted-foreground">加载中…</div>;
}
if (totalAll === 0) {
return (
<div className="py-20 text-center text-sm text-muted-foreground">
该任务暂无涉及资产
<p className="mt-1 text-xs">资产在任务执行过程中由 agent 发现并记录</p>
</div>
);
}
const cur = byType[tab] ?? [];
const slice = cur.slice(page * size, page * size + size);
return (
<Tabs orientation="vertical" value={tab} onValueChange={setTab} className="items-start gap-4">
{/* left nav */}
<div className="w-36 shrink-0 rounded-lg border bg-card p-1 shadow-sm">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors",
tab === t.key
? "bg-accent text-accent-foreground font-medium"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
<t.icon className="size-3.5 shrink-0" />
<span className="flex-1 text-left">{t.label}</span>
<span className="tabular-nums text-xs opacity-70">{counts[t.key] ?? 0}</span>
</button>
))}
</div>
{/* right content */}
<div className="min-w-0 flex-1 flex flex-col gap-4">
<TabsContent value="root_domain" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard cols={["域名", "ICP 备案"]} total={cur.length} page={page} size={size} onSize={setSize} onPage={setPage}>
{slice.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-mono text-xs font-medium">{a.domain}</TableCell>
<TableCell className="text-xs">{a.icp || "—"}</TableCell>
</TableRow>
))}
</AssetCard>
</TabsContent>
<TabsContent value="ip" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard cols={["IP", "C段", "绑定域名", "开放端口"]} total={cur.length} page={page} size={size} onSize={setSize} onPage={setPage}>
{slice.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-mono text-xs font-medium">{a.ip}</TableCell>
<TableCell className="font-mono text-xs">{a.c_segment || "—"}</TableCell>
<TableCell><Chips items={a.bound_domains ?? []} mono /></TableCell>
<TableCell>
<Chips
items={(a.open_ports ?? []).map((p) =>
p.service ? `${p.port}/${p.service}` : String(p.port),
)}
mono
/>
</TableCell>
</TableRow>
))}
</AssetCard>
</TabsContent>
<TabsContent value="subdomain" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard cols={["域名", "根域名", "解析类型", "解析值"]} total={cur.length} page={page} size={size} onSize={setSize} onPage={setPage}>
{slice.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-mono text-xs font-medium">{a.domain}</TableCell>
<TableCell className="font-mono text-xs">{a.root_domain || "—"}</TableCell>
<TableCell className="text-xs">{a.record_type || "—"}</TableCell>
<TableCell className="max-w-xs truncate font-mono text-xs">{(Array.isArray(a.record_value) ? a.record_value.join(", ") : a.record_value) || "—"}</TableCell>
</TableRow>
))}
</AssetCard>
</TabsContent>
<TabsContent value="service" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard
cols={["地址 / 服务", "状态码", "标题", "响应长度", "指纹", "认证"]}
total={cur.length}
page={page}
size={size}
onSize={setSize}
onPage={setPage}
>
{slice.map((a) => {
const isHttp = a.service_type === "http";
const addr = isHttp
? (a.url || "")
: (a.service_name || [a.ip || a.domain, a.port].filter(Boolean).join(":"));
return (
<TableRow key={a.id}>
<TableCell className="max-w-xs truncate font-mono text-xs" title={addr}>{addr || "—"}</TableCell>
<TableCell>
{a.status_code != null ? (
<span className={cn("font-mono text-xs font-semibold tabular-nums", statusTone(a.status_code))}>
{a.status_code}
</span>
) : "—"}
</TableCell>
<TableCell className="max-w-[12rem] truncate text-xs">{a.page_title || "—"}</TableCell>
<TableCell className="text-xs tabular-nums">{fmtBytes(a.content_length)}</TableCell>
<TableCell><Chips items={a.technologies ?? []} /></TableCell>
<TableCell>
{(a.auth ?? []).length === 0 ? (
<span className="text-xs text-muted-foreground">—</span>
) : (
(a.auth ?? []).map((authItem, i) => {
const item = authItem as Record<string, string>;
return (
<span key={i} className="inline-flex items-center gap-1 text-[11px]">
<KeyRoundIcon className="size-3 text-muted-foreground" />
<span className="font-mono">{item.type || item.username || "认证"}</span>
</span>
);
})
)}
</TableCell>
</TableRow>
);
})}
</AssetCard>
</TabsContent>
<TabsContent value="endpoint" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard cols={["方法", "完整地址", "参数"]} total={cur.length} page={page} size={size} onSize={setSize} onPage={setPage}>
{slice.map((a) => (
<TableRow key={a.id}>
<TableCell className="w-16"><MethodBadge method={a.method || ""} /></TableCell>
<TableCell className="max-w-sm truncate font-mono text-xs" title={a.url}>{a.url || "—"}</TableCell>
<TableCell>
<Chips
items={(a.params ?? []).map((p) => {
const item = p as Record<string, string>;
return item.name ? `${item.name}(${item.location || "?"})` : "?";
})}
mono
/>
</TableCell>
</TableRow>
))}
</AssetCard>
</TabsContent>
</div>
</Tabs>
);
}
@@ -0,0 +1,90 @@
"use client";
import * as React from "react";
import { ChevronRightIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import { StatusBadge } from "@/components/status-badge";
import { api } from "@/lib/api";
import type { Finding } from "@/lib/types";
function Row({ f }: { f: Finding }) {
const [open, setOpen] = React.useState(false);
return (
<div className="border-b last:border-b-0">
<button
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm hover:bg-accent/40"
>
<ChevronRightIcon
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
open && "rotate-90",
)}
/>
<StatusBadge domain="severity" value={f.severity} dot />
<span className="w-20 shrink-0 font-medium">{f.vulnclass}</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{f.summary}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{new Date(f.ts).toLocaleString("zh-CN")}
</span>
</button>
{open && (
<div className="bg-muted/30 px-4 pb-4 pl-11">
<div className="mb-1 text-xs font-medium text-muted-foreground">
证据 / PoC
</div>
<pre className="overflow-auto rounded-md border bg-background p-3 font-mono text-xs whitespace-pre-wrap">
{f.evidence}
</pre>
</div>
)}
</div>
);
}
export function FindingsTab({ taskId }: { taskId: string }) {
const [findings, setFindings] = React.useState<Finding[]>([]);
React.useEffect(() => {
let active = true;
const load = () => {
api
.findings(taskId)
.then((fs) => {
if (active) setFindings(fs);
})
.catch(() => {});
};
load();
const t = setInterval(load, 3000);
return () => {
active = false;
clearInterval(t);
};
}, [taskId]);
const items = findings
.filter((f) => f.task_id === taskId)
.sort((a, b) => {
const order = { high: 0, medium: 1, low: 2 };
return order[a.severity] - order[b.severity];
});
return (
<Card className="overflow-hidden py-0">
<CardContent className="px-0">
{items.map((f) => (
<Row key={f.id} f={f} />
))}
{items.length === 0 && (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
本任务暂无确认发现。
</p>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,633 @@
"use client";
"use no memo";
import * as React from "react";
import {
Background,
BackgroundVariant,
Controls,
type Edge as RFEdge,
Handle,
MarkerType,
MiniMap,
type Node as RFNode,
type NodeProps,
Panel,
Position,
ReactFlow,
ReactFlowProvider,
useEdgesState,
useNodesState,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import {
Bug,
Compass,
Flag,
FlaskConical,
Lightbulb,
type LucideIcon,
Target,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import { StatusBadge } from "@/components/status-badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { toneClasses, type Tone } from "@/lib/status";
import { api } from "@/lib/api";
import type { Edge, ExploreKind, TaskNode } from "@/lib/types";
const relLabel: Record<string, string> = {
spawns: "派生",
derived_from: "意图链",
yields: "产出",
proves: "证明",
};
const relColor: Record<string, string> = {
spawns: "#10b981",
derived_from: "#6366f1",
yields: "#f59e0b",
proves: "#ef4444",
};
type TypeMeta = {
label: string;
icon: LucideIcon;
iconBg: string; // Dify 风格实心图标块底色
chip: string; // 抽屉里用的柔和底色
hex: string; // minimap / 连线取色
};
const typeMeta: Record<ExploreKind, TypeMeta> = {
task: {
label: "根任务",
icon: Flag,
iconBg: "bg-slate-500",
chip: "bg-slate-500/15 text-slate-600 dark:text-slate-300",
hex: "#64748b",
},
begin: {
label: "起点",
icon: Flag,
iconBg: "bg-slate-500",
chip: "bg-slate-500/15 text-slate-600 dark:text-slate-300",
hex: "#64748b",
},
goal: {
label: "目标",
icon: Target,
iconBg: "bg-emerald-500",
chip: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
hex: "#10b981",
},
hint: {
label: "提示",
icon: Lightbulb,
iconBg: "bg-violet-500",
chip: "bg-violet-500/15 text-violet-600 dark:text-violet-400",
hex: "#8b5cf6",
},
intent: {
label: "意图",
icon: Compass,
iconBg: "bg-blue-500",
chip: "bg-blue-500/15 text-blue-600 dark:text-blue-400",
hex: "#3b82f6",
},
fact: {
label: "事实",
icon: FlaskConical,
iconBg: "bg-amber-500",
chip: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
hex: "#f59e0b",
},
finding: {
label: "漏洞",
icon: Bug,
iconBg: "bg-rose-500",
chip: "bg-rose-500/15 text-rose-600 dark:text-rose-400",
hex: "#f43f5e",
},
};
// The task root is now an origin fact (kind='fact', state='origin') rather than a
// dedicated 'begin' node. Render/layout it with the "起点" visual (the `begin`
// entry above) so the graph root still reads as the origin.
function viewKind(n: TaskNode): ExploreKind {
return n.type === "fact" && n.state === "origin" ? "begin" : n.type;
}
function prioTone(p: number): Tone {
return p >= 8 ? "red" : p >= 5 ? "amber" : "slate";
}
// 各类型节点在卡片上展示的字段:payload 通常是一段 JSON 字符串,
// 按类型取其中的 summary / text / description 作为显示内容。
const summaryFields: Record<ExploreKind, string[]> = {
task: ["summary", "description"],
begin: ["summary", "description"],
goal: ["text"],
hint: [],
intent: ["summary"],
fact: ["summary"],
finding: ["summary"],
};
// 解析 payload 得到节点显示文案;解析失败或字段缺失时回退到原始 payload。
function nodeSummary(n: TaskNode): string {
const raw = n.payload ?? "";
const fields = summaryFields[viewKind(n)] ?? [];
if (fields.length && raw.trim()) {
try {
const obj: unknown = JSON.parse(raw);
if (obj && typeof obj === "object") {
for (const f of fields) {
const v = (obj as Record<string, unknown>)[f];
if (typeof v === "string" && v.trim()) return v;
}
}
} catch {
/* 非 JSON:按原始内容显示 */
}
}
return raw;
}
const COL_W = 360;
const ROW_H = 140;
type ExploreNodeData = { node: TaskNode };
type ExploreRFNode = RFNode<ExploreNodeData, "explore">;
function pushMap(m: Map<string, string[]>, k: string, v: string) {
const a = m.get(k);
if (a) a.push(v);
else m.set(k, [v]);
}
// 按连线的“递进深度”自动分层:顺着边从左往右排(意图→事实,事实/漏洞→再生意图…),
// 而不是按节点类型固定分列——这样反向/回环的边才不会互相缠绕。
// 1) DFS 断环:指向递归栈中节点的边视为回边,不参与分层(处理 意图↔事实 这类回环);
// 2) 最长路径分层:谁在链路上更靠后,列就越靠右;
// 3) 重心排序:每层按相邻层邻居的平均位置重排,让有连线的节点对齐、减少交叉;
// 4) 每层整体垂直居中。
function computeLayout(
nodes: TaskNode[],
edges: Edge[],
): Map<string, { x: number; y: number }> {
const order = nodes.map((n) => n.id);
const idset = new Set(order);
const valid = edges.filter((e) => idset.has(e.src) && idset.has(e.dst));
const key = (s: string, d: string) => `${s}->${d}`;
const out = new Map<string, string[]>();
for (const e of valid) pushMap(out, e.src, e.dst);
// 1) 迭代式 DFS 断环
const seen = new Map<string, 0 | 1 | 2>();
const back = new Set<string>();
for (const root of order) {
if ((seen.get(root) ?? 0) !== 0) continue;
const stack: Array<{ u: string; i: number }> = [{ u: root, i: 0 }];
seen.set(root, 1);
while (stack.length) {
const top = stack[stack.length - 1];
const nbrs = out.get(top.u) ?? [];
if (top.i >= nbrs.length) {
seen.set(top.u, 2);
stack.pop();
continue;
}
const v = nbrs[top.i++];
const s = seen.get(v) ?? 0;
if (s === 1) back.add(key(top.u, v)); // 回边
else if (s === 0) {
seen.set(v, 1);
stack.push({ u: v, i: 0 });
}
}
}
// 2) 最长路径分层(忽略回边)
const dagSucc = new Map<string, string[]>();
const indeg = new Map<string, number>(order.map((id) => [id, 0]));
for (const e of valid) {
if (back.has(key(e.src, e.dst))) continue;
pushMap(dagSucc, e.src, e.dst);
indeg.set(e.dst, (indeg.get(e.dst) ?? 0) + 1);
}
const layer = new Map<string, number>(order.map((id) => [id, 0]));
const queue = order.filter((id) => (indeg.get(id) ?? 0) === 0);
for (let qi = 0; qi < queue.length; qi++) {
const u = queue[qi];
for (const v of dagSucc.get(u) ?? []) {
layer.set(v, Math.max(layer.get(v) ?? 0, (layer.get(u) ?? 0) + 1));
const d = (indeg.get(v) ?? 0) - 1;
indeg.set(v, d);
if (d === 0) queue.push(v);
}
}
// 3) 按层分列
const maxLayer = Math.max(0, ...layer.values());
const columns: string[][] = Array.from({ length: maxLayer + 1 }, () => []);
for (const id of order) columns[layer.get(id) ?? 0].push(id);
// 重心排序:只统计相邻层之间的连线
const colIndexOf = new Map<string, number>();
columns.forEach((ids, c) => ids.forEach((id) => colIndexOf.set(id, c)));
const neighbors = new Map<string, string[]>();
for (const e of valid) {
const cs = colIndexOf.get(e.src);
const cd = colIndexOf.get(e.dst);
if (cs === undefined || cd === undefined || Math.abs(cs - cd) !== 1) continue;
pushMap(neighbors, e.src, e.dst);
pushMap(neighbors, e.dst, e.src);
}
const sweep = (dir: 1 | -1) => {
const start = dir === 1 ? 1 : columns.length - 2;
const end = dir === 1 ? columns.length : -1;
for (let c = start; c !== end; c += dir) {
const ref = c - dir;
const refIndex = new Map(columns[ref].map((id, i) => [id, i]));
const scored = columns[c].map((id, i) => {
const ns = (neighbors.get(id) ?? [])
.map((nid) => refIndex.get(nid))
.filter((v): v is number => v !== undefined);
const bary = ns.length ? ns.reduce((a, b) => a + b, 0) / ns.length : i;
return { id, bary, i };
});
scored.sort((a, b) => a.bary - b.bary || a.i - b.i);
columns[c] = scored.map((s) => s.id);
}
};
for (let iter = 0; iter < 4; iter++) {
sweep(1);
sweep(-1);
}
// 4) 垂直居中
const maxCount = Math.max(1, ...columns.map((c) => c.length));
const midline = ((maxCount - 1) * ROW_H) / 2;
const pos = new Map<string, { x: number; y: number }>();
columns.forEach((ids, c) => {
const startY = midline - ((ids.length - 1) * ROW_H) / 2;
ids.forEach((id, i) => pos.set(id, { x: c * COL_W, y: startY + i * ROW_H }));
});
return pos;
}
// Dify 工作流风格节点:白色圆角卡片 + 实心配色图标块 + 选中/运行蓝色描边。
function ExploreNode({ data, selected }: NodeProps<ExploreRFNode>) {
const n = data.node;
const kind = viewKind(n);
const meta = typeMeta[kind] ?? typeMeta.intent;
const Icon = meta.icon;
const showState = n.type === "goal" || n.type === "intent";
const showPriority = (n.type === "goal" || n.type === "intent") && n.priority > 0;
const isLive = n.type === "intent" && n.state === "running";
return (
<div
className={cn(
"w-[240px] cursor-pointer rounded-2xl border-2 transition-colors",
selected ? "border-blue-500" : isLive ? "border-blue-400/70" : "border-transparent",
)}
>
<div className="group relative rounded-[14px] border border-black/[0.06] bg-white shadow-sm transition-shadow hover:shadow-lg dark:border-white/10 dark:bg-neutral-900">
<Handle
type="target"
position={Position.Left}
className="!size-2 !border-2 !border-neutral-300 !bg-white opacity-0 transition-opacity group-hover:opacity-100 dark:!border-neutral-600 dark:!bg-neutral-800"
/>
{/* 头部:实心图标块 + 类型标题 + 状态点 / 优先级 */}
<div className="flex items-center gap-2 px-3 pt-3 pb-1.5">
<span
className={cn(
"flex size-6 shrink-0 items-center justify-center rounded-lg shadow-sm",
meta.iconBg,
)}
>
<Icon className="size-3.5 text-white" />
</span>
<span className="grow truncate text-[13px] font-semibold text-neutral-700 dark:text-neutral-100">
{meta.label}
</span>
{isLive && (
<span className="relative flex size-2 shrink-0">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-blue-400 opacity-70" />
<span className="relative inline-flex size-2 rounded-full bg-blue-500" />
</span>
)}
{showPriority && (
<span
className={cn(
"shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-semibold tabular-nums",
toneClasses[prioTone(n.priority)],
)}
>
P{n.priority}
</span>
)}
</div>
{/* 摘要 */}
<div className="line-clamp-3 px-3 pb-2 text-[13px] leading-snug text-neutral-500 dark:text-neutral-400">
{nodeSummary(n) || meta.label}
</div>
{/* 底部:状态徽章 + 来源/ID */}
<div className="flex items-center justify-between gap-2 px-3 pb-2.5">
{showState ? (
<StatusBadge
domain={n.type === "goal" ? "goal" : "intent"}
value={n.state}
dot
className="px-1.5 py-0 text-[10px]"
/>
) : (
<span className="text-[10px] text-neutral-400 dark:text-neutral-500">
{meta.label}
</span>
)}
<span className="shrink-0 font-mono text-[10px] text-neutral-400 dark:text-neutral-500">
#{n.id}
</span>
</div>
<Handle
type="source"
position={Position.Right}
className="!size-2 !border-2 !border-neutral-300 !bg-white opacity-0 transition-opacity group-hover:opacity-100 dark:!border-neutral-600 dark:!bg-neutral-800"
/>
</div>
</div>
);
}
const nodeTypes = { explore: ExploreNode };
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-start gap-3 py-1.5 text-sm">
<span className="text-muted-foreground w-14 shrink-0">{label}</span>
<span className="text-foreground min-w-0 flex-1 break-words">{children}</span>
</div>
);
}
// 点击节点后弹出的抽屉:上半展示可读摘要,下半展示节点原始 JSON。
function NodeDetailSheet({
node,
onOpenChange,
}: {
node: TaskNode | null;
onOpenChange: (open: boolean) => void;
}) {
const [copied, setCopied] = React.useState(false);
const meta = node ? (typeMeta[viewKind(node)] ?? typeMeta.intent) : null;
const Icon = meta?.icon;
const raw = node ? JSON.stringify(node, null, 2) : "";
const copy = () => {
void navigator.clipboard?.writeText(raw).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
});
};
return (
<Sheet open={node !== null} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 p-0 data-[side=right]:sm:max-w-md"
>
{node && meta && Icon && (
<>
<SheetHeader className="border-b p-4">
<div className="flex items-center gap-2.5 pr-8">
<span
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-lg shadow-sm",
meta.iconBg,
)}
>
<Icon className="size-4 text-white" />
</span>
<div className="min-w-0">
<SheetTitle className="leading-tight">{meta.label}</SheetTitle>
<span className="text-muted-foreground font-mono text-xs">{node.id}</span>
</div>
</div>
</SheetHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="flex w-full min-w-0 flex-col gap-4 p-4">
<section>
<h4 className="text-muted-foreground mb-1 text-xs font-medium">属性</h4>
<DetailRow label="类型">{meta.label}</DetailRow>
<DetailRow label="状态">
{node.type === "goal" || node.type === "intent" ? (
<StatusBadge
domain={node.type === "goal" ? "goal" : "intent"}
value={node.state}
dot
className="px-1.5 py-0 text-[10px]"
/>
) : (
<span className="font-mono text-xs">{node.state}</span>
)}
</DetailRow>
<DetailRow label="优先级">
<span className="tabular-nums">{node.priority}</span>
</DetailRow>
<DetailRow label="来源">
<span className="font-mono text-xs">{node.origin}</span>
</DetailRow>
<DetailRow label="时间">{new Date(node.ts).toLocaleString("zh-CN")}</DetailRow>
</section>
<section className="border-t pt-3">
<div className="mb-1.5 flex items-center justify-between">
<h4 className="text-muted-foreground text-xs font-medium">原始数据</h4>
<button
type="button"
onClick={copy}
className="text-muted-foreground hover:text-foreground text-xs transition-colors"
>
{copied ? "已复制" : "复制"}
</button>
</div>
<pre className="bg-muted/50 text-foreground max-w-full overflow-hidden rounded-md border p-3 font-mono text-xs leading-relaxed break-all whitespace-pre-wrap">
{raw}
</pre>
</section>
</div>
</ScrollArea>
</>
)}
</SheetContent>
</Sheet>
);
}
function GraphInner({ taskId }: { taskId: string }) {
const [rfNodes, setRfNodes, onNodesChange] = useNodesState<ExploreRFNode>([]);
const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState<RFEdge>([]);
const [isEmpty, setIsEmpty] = React.useState(true);
const [selected, setSelected] = React.useState<TaskNode | null>(null);
React.useEffect(() => {
let cancelled = false;
const load = () => {
api
.explorationGraph(taskId)
.then((g) => {
if (cancelled) return;
const nodes = g.nodes ?? [];
const edges: Edge[] = g.edges ?? [];
setIsEmpty(nodes.length === 0);
// 抽屉打开时,让其中的数据随轮询保持最新。
setSelected((cur) => (cur ? (nodes.find((n) => n.id === cur.id) ?? cur) : cur));
// 目标指向「执行中」意图的连线做流动动画,直观展示活跃探索路径。
const liveTargets = new Set(
nodes.filter((n) => n.type === "intent" && n.state === "running").map((n) => n.id),
);
const layout = computeLayout(nodes, edges);
setRfNodes((prev) => {
const prevPos = new Map(prev.map((p) => [p.id, p.position]));
return nodes.map((n) => ({
id: n.id,
type: "explore" as const,
position: prevPos.get(n.id) ?? layout.get(n.id) ?? { x: 0, y: 0 },
data: { node: n },
sourcePosition: Position.Right,
targetPosition: Position.Left,
}));
});
setRfEdges(
edges.map((e, i) => {
const color = relColor[e.rel] ?? "#94a3b8";
const animated = liveTargets.has(e.dst);
return {
id: `e${i}-${e.src}-${e.dst}`,
source: e.src,
target: e.dst,
label: relLabel[e.rel] ?? e.rel,
type: "default",
animated,
labelShowBg: false,
labelStyle: { fontSize: 10, fontWeight: 600, fill: color },
style: {
stroke: color,
strokeWidth: 2,
strokeOpacity: animated ? 0.9 : 0.55,
},
markerEnd: { type: MarkerType.ArrowClosed, color, width: 14, height: 14 },
} satisfies RFEdge;
}),
);
})
.catch(() => {
/* keep last good data */
});
};
load();
const timer = setInterval(load, 3000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [taskId, setRfNodes, setRfEdges]);
return (
<>
<ReactFlow
nodes={rfNodes}
edges={rfEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={(_, node) => setSelected(node.data.node)}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
proOptions={{ hideAttribution: true }}
minZoom={0.1}
className="!bg-[#f0f2f7] dark:!bg-neutral-950"
>
<Background
variant={BackgroundVariant.Dots}
gap={16}
size={1}
className="text-neutral-400/50 dark:text-neutral-700/60"
/>
<Controls
showInteractive={false}
className="!rounded-lg !border !shadow-sm [&>button]:!border-border [&>button]:!bg-card [&>button:hover]:!bg-accent [&_svg]:!fill-foreground"
/>
<MiniMap
pannable
zoomable
className="!bg-card !rounded-lg !border !shadow-sm"
maskColor="rgb(148 163 184 / 0.18)"
nodeColor={(node) => {
const data = node.data as ExploreNodeData | undefined;
const k = data?.node ? viewKind(data.node) : "intent";
return typeMeta[k]?.hex ?? "#94a3b8";
}}
nodeStrokeWidth={0}
nodeBorderRadius={4}
/>
<Panel position="top-left">
<div className="bg-card/95 flex flex-col gap-2.5 rounded-lg border p-3 text-xs shadow-sm backdrop-blur">
{isEmpty && <span className="text-muted-foreground">暂无探索数据</span>}
<div className="flex flex-wrap gap-x-3 gap-y-1.5">
{(["begin", "goal", "intent", "fact", "finding", "hint"] as ExploreKind[]).map((k) => {
const m = typeMeta[k];
const Icon = m.icon;
return (
<span key={k} className="text-foreground inline-flex items-center gap-1.5">
<span className={cn("flex size-4 items-center justify-center rounded", m.iconBg)}>
<Icon className="size-2.5 text-white" />
</span>
{m.label}
</span>
);
})}
</div>
<div className="border-border/60 text-muted-foreground flex flex-wrap gap-x-3 gap-y-1.5 border-t pt-2">
{Object.entries(relLabel).map(([k, v]) => (
<span key={k} className="inline-flex items-center gap-1.5">
<span className="h-0.5 w-4 rounded-full" style={{ backgroundColor: relColor[k] }} />
{v}
</span>
))}
</div>
</div>
</Panel>
</ReactFlow>
<NodeDetailSheet node={selected} onOpenChange={(o) => !o && setSelected(null)} />
</>
);
}
export function GraphTab({ taskId }: { taskId: string }) {
return (
<Card>
<CardContent className="p-0">
<div className="h-[72vh] w-full overflow-hidden rounded-xl">
<ReactFlowProvider>
<GraphInner taskId={taskId} />
</ReactFlowProvider>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,217 @@
"use client";
import * as React from "react";
import { CheckIcon, RefreshCwIcon, ShieldAlertIcon, XIcon } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import type { InterceptApprovalRow } from "@/lib/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
function fmtTime(s: string) {
if (!s) return "—";
return new Date(s).toLocaleString("zh-CN", {
month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit", second: "2-digit",
});
}
function AgentChip({ name }: { name: string }) {
if (!name) return <span className="text-muted-foreground text-xs">—</span>;
const color =
name === "planner" ? "bg-amber-500" :
name === "mainagent" ? "bg-primary" :
"bg-sky-600";
return (
<span className={`inline-block rounded px-1.5 py-0.5 text-[10px] font-medium text-white ${color}`}>
{name}
</span>
);
}
function StatusBadge({ status }: { status: string }) {
if (status === "pending") return <Badge variant="outline" className="border-amber-400 text-amber-600">待审批</Badge>;
if (status === "allowed") return <Badge variant="outline" className="border-emerald-500 text-emerald-600">已允许</Badge>;
if (status === "denied") return <Badge variant="outline" className="border-red-500 text-red-600">已拒绝</Badge>;
return <Badge variant="outline" className="text-muted-foreground">已超时</Badge>;
}
function InputPreview({ input }: { input: Record<string, unknown> }) {
const full = JSON.stringify(input, null, 2);
const s = JSON.stringify(input);
const short = s.length > 100 ? s.slice(0, 100) + "…" : s;
return (
<Tooltip>
<TooltipTrigger asChild>
<code className="block w-full cursor-default truncate font-mono text-[11px] text-muted-foreground">{short}</code>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-sm">
<pre className="whitespace-pre-wrap break-all font-mono text-xs">{full}</pre>
</TooltipContent>
</Tooltip>
);
}
interface Props {
taskId: string;
}
export function InterceptTab({ taskId }: Props) {
const [rows, setRows] = React.useState<InterceptApprovalRow[]>([]);
const [loading, setLoading] = React.useState(true);
const [deciding, setDeciding] = React.useState<number | null>(null);
const load = React.useCallback(async () => {
try {
setRows(await api.interceptTask(taskId));
} catch {
// ignore
} finally {
setLoading(false);
}
}, [taskId]);
React.useEffect(() => {
load();
const t = setInterval(load, 5000);
return () => clearInterval(t);
}, [load]);
async function decide(id: number, decision: "allowed" | "denied") {
if (deciding !== null) return;
setDeciding(id);
try {
await api.interceptDecide(id, decision);
toast.success(decision === "allowed" ? "已允许执行" : "已拒绝执行");
setRows((prev) =>
prev.map((r) => r.id === id ? { ...r, status: decision, decided_at: new Date().toISOString() } : r),
);
} catch (e) {
toast.error((e as Error).message);
} finally {
setDeciding(null);
}
}
const pending = rows.filter((r) => r.status === "pending");
if (!loading && rows.length === 0) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center">
<ShieldAlertIcon className="h-8 w-8 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">此任务暂无拦截审批记录</p>
</div>
);
}
return (
<TooltipProvider delayDuration={300}>
<div className="flex flex-col gap-4">
{/* header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium">
<ShieldAlertIcon className="h-4 w-4 text-muted-foreground" />
拦截审批
{pending.length > 0 && (
<Badge className="rounded-full bg-amber-500 text-white text-[10px] px-1.5">
{pending.length} 待审批
</Badge>
)}
</div>
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
<RefreshCwIcon className="mr-1 h-3.5 w-3.5" />
刷新
</Button>
</div>
{/* pending block */}
{pending.length > 0 && (
<div className="rounded-lg border border-amber-400/40 bg-amber-50/30 dark:bg-amber-950/10">
<div className="flex items-center gap-2 border-b border-amber-400/30 px-4 py-2.5 text-xs font-medium text-amber-700 dark:text-amber-400">
<ShieldAlertIcon className="h-3.5 w-3.5" />
待处理({pending.length})— 超时 5 分钟后自动拒绝
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead className="w-28">Agent</TableHead>
<TableHead>工具</TableHead>
<TableHead>规则</TableHead>
<TableHead className="w-48">参数</TableHead>
<TableHead>时间</TableHead>
<TableHead className="w-32 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pending.map((row) => (
<TableRow key={row.id}>
<TableCell className="text-muted-foreground text-xs">{row.id}</TableCell>
<TableCell><AgentChip name={row.agent_name} /></TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{row.rule_name || "—"}</TableCell>
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button size="sm" className="h-7 px-2 text-xs" disabled={deciding === row.id} onClick={() => decide(row.id, "allowed")}>
<CheckIcon className="mr-1 h-3 w-3" />允许
</Button>
<Button size="sm" variant="destructive" className="h-7 px-2 text-xs" disabled={deciding === row.id} onClick={() => decide(row.id, "denied")}>
<XIcon className="mr-1 h-3 w-3" />拒绝
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* history table */}
<div className="rounded-lg border">
<div className="border-b px-4 py-2.5 text-xs font-medium text-muted-foreground">
全部记录({rows.length})
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead className="w-28">Agent</TableHead>
<TableHead>工具</TableHead>
<TableHead>规则</TableHead>
<TableHead className="w-48">参数</TableHead>
<TableHead>状态</TableHead>
<TableHead>申请时间</TableHead>
<TableHead>决定时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id} className={row.status === "pending" ? "opacity-40" : ""}>
<TableCell className="text-muted-foreground text-xs">{row.id}</TableCell>
<TableCell><AgentChip name={row.agent_name} /></TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{row.rule_name || "—"}</TableCell>
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
<TableCell><StatusBadge status={row.status} /></TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{row.decided_at ? fmtTime(row.decided_at) : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,244 @@
"use client";
import * as React from "react";
import {
ActivityIcon,
AlertTriangleIcon,
BugIcon,
ClockIcon,
ShieldCheckIcon,
TargetIcon,
} from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { StatusBadge } from "@/components/status-badge";
import { api } from "@/lib/api";
import type { Task, Stats, TaskNode, Finding } from "@/lib/types";
function StatCard({
label,
value,
sub,
icon: Icon,
}: {
label: string;
value: React.ReactNode;
sub?: string;
icon: React.ElementType;
}) {
return (
<Card className="gap-1.5">
<CardHeader className="pb-0">
<CardDescription className="flex items-center gap-1.5">
<Icon className="size-3.5" /> {label}
</CardDescription>
<CardTitle className="text-2xl tabular-nums">{value}</CardTitle>
</CardHeader>
{sub && (
<CardContent className="text-xs text-muted-foreground">{sub}</CardContent>
)}
</Card>
);
}
export function OverviewTab({ taskId }: { taskId: string }) {
const [task, setTask] = React.useState<Task | null>(null);
const [stats, setStats] = React.useState<Stats | null>(null);
const [intents, setIntents] = React.useState<TaskNode[]>([]);
const [findings, setFindings] = React.useState<Finding[]>([]);
React.useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const [tasksResp, statsResp, intentsResp, findingsResp] =
await Promise.all([
api.tasks(),
api.stats(taskId),
api.intents(taskId),
api.findings(taskId),
]);
if (cancelled) return;
setTask(tasksResp.tasks.find((t) => t.id === taskId) ?? null);
setStats(statsResp);
setIntents(intentsResp);
setFindings(findingsResp);
} catch {
// transient errors are ignored; the next poll will retry
}
};
load();
const timer = setInterval(load, 3000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [taskId]);
const running = intents.filter((i) => i.state === "running");
const open = intents.filter((i) => i.state === "open");
const blocked = intents.filter((i) => i.state === "blocked");
const taskFindings = findings.filter((f) => f.task_id === taskId);
const goalsPct = task?.goals_total
? Math.round(((task.goals_met ?? 0) / task.goals_total) * 100)
: 0;
return (
<div className="flex flex-col gap-4">
{/* Heartbeat */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ActivityIcon className="size-4 text-blue-500" /> 心跳
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<div className="text-xs text-muted-foreground">引擎态</div>
<StatusBadge
domain="engine"
value={stats?.engine_mode ?? task?.engine_mode ?? "idle"}
dot
className="mt-1"
/>
</div>
<div>
<div className="text-xs text-muted-foreground">运行中 Worker</div>
<div className="mt-1 text-lg font-semibold tabular-nums">
{running.length}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">最近活动</div>
<div className="mt-1 inline-flex items-center gap-1 text-sm">
<ClockIcon className="size-3.5" />
{task?.last_activity
? new Date(task.last_activity).toLocaleTimeString("zh-CN")
: "—"}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">
目标 {task?.goals_met ?? 0}/{task?.goals_total ?? 0}
</div>
<Progress value={goalsPct} className="mt-2" />
</div>
{task?.completed_unix && task.completed_unix > 0 ? (
<div>
<div className="text-xs text-muted-foreground">完成时间</div>
<div className="mt-1 inline-flex items-center gap-1 text-sm">
<ClockIcon className="size-3.5" />
{new Date(task.completed_unix * 1000).toLocaleString("zh-CN")}
</div>
</div>
) : null}
</CardContent>
</Card>
{/* Work set */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm">
<TargetIcon className="size-4" /> 进行中意图
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{running.slice(0, 6).map((i) => (
<div key={i.id} className="flex items-center gap-2 text-sm">
<StatusBadge domain="intent" value={i.state} />
<span className="min-w-0 flex-1 truncate">{i.payload}</span>
</div>
))}
{running.length === 0 && (
<p className="text-sm text-muted-foreground">暂无进行中意图</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm">
<AlertTriangleIcon className="size-4 text-amber-500" /> 需要关注
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 text-sm">
<div>
<div className="text-2xl font-semibold tabular-nums text-red-600">
{taskFindings.length}
</div>
<div className="text-xs text-muted-foreground">确认漏洞</div>
</div>
<div>
<div className="text-2xl font-semibold tabular-nums text-blue-600">
{running.length}
</div>
<div className="text-xs text-muted-foreground">执行中</div>
</div>
<div>
<div className="text-2xl font-semibold tabular-nums">
{open.length}
</div>
<div className="text-xs text-muted-foreground">frontier 待领</div>
</div>
<div>
<div className="text-2xl font-semibold tabular-nums text-red-600">
{blocked.length}
</div>
<div className="text-xs text-muted-foreground">被拦意图</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm">
<BugIcon className="size-4 text-red-500" /> 最近发现
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{taskFindings.slice(0, 6).map((f) => (
<div key={f.id} className="flex items-center gap-2 text-sm">
<StatusBadge domain="severity" value={f.severity} dot />
<span className="min-w-0 flex-1 truncate">{f.summary}</span>
</div>
))}
{taskFindings.length === 0 && (
<p className="text-sm text-muted-foreground">暂无发现</p>
)}
</CardContent>
</Card>
</div>
{/* Stat cards */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-3">
<StatCard
label="待领意图"
value={open.length}
icon={ShieldCheckIcon}
sub="frontier 开放"
/>
<StatCard
label="确认发现"
value={taskFindings.length}
icon={BugIcon}
sub="本任务"
/>
<StatCard
label="意图总数"
value={intents.length}
icon={AlertTriangleIcon}
sub="本任务全部意图"
/>
</div>
</div>
);
}
@@ -0,0 +1,75 @@
"use client";
import * as React from "react";
import { FileTextIcon, CopyIcon, CheckIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { api } from "@/lib/api";
export function ReportTab({ taskId }: { taskId: string }) {
const [report, setReport] = React.useState<string>("");
const [loading, setLoading] = React.useState(true);
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
let active = true;
setLoading(true);
api
.report(taskId)
.then((text) => {
if (active) setReport(text);
})
.catch(() => {
if (active) setReport("");
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [taskId]);
function copy() {
if (!report) return;
navigator.clipboard?.writeText(report);
setCopied(true);
toast.success("已复制 Markdown");
setTimeout(() => setCopied(false), 1500);
}
return (
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2 text-sm">
<FileTextIcon className="size-4" /> 渗透测试报告(Markdown)
</CardTitle>
<div className="flex gap-2">
{report && (
<Button size="sm" variant="outline" onClick={copy}>
{copied ? <CheckIcon /> : <CopyIcon />} 复制
</Button>
)}
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed py-16 text-sm text-muted-foreground">
<FileTextIcon className="size-8 opacity-40" />
加载中…
</div>
) : report ? (
<pre className="max-h-[60vh] overflow-auto rounded-md border bg-muted/40 p-4 font-mono text-xs whitespace-pre-wrap">
{report}
</pre>
) : (
<div className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed py-16 text-sm text-muted-foreground">
<FileTextIcon className="size-8 opacity-40" />
暂无报告
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,709 @@
"use client";
import * as React from "react";
import {
RadioIcon,
BrainIcon,
UserIcon,
Loader2Icon,
ZapOffIcon,
SendIcon,
SquareIcon,
ClockIcon,
CircleCheckIcon,
CircleXIcon,
CircleSlashIcon,
ShieldAlertIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Transcript } from "@/components/transcript";
import { TodoPopover } from "@/components/todo-popover";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api, sseUrl } from "@/lib/api";
import { MOCK } from "@/lib/mock/enabled";
import type {
Activity,
InterceptApprovalRow,
Session,
SessionStatus,
TaskNode,
TokenTotal,
} from "@/lib/types";
// statusIcon maps a session status to its icon. Worker terminal states are
// distinct & color-coded: 完成(绿勾圈) / 取消停止(琥珀斜杠圈) / 出错(红叉圈) /
// 步数耗尽(紫). running=蓝色转圈, pending(待领取)=灰时钟.
function statusIcon(status: SessionStatus) {
switch (status) {
case "running": // 执行中
return <Loader2Icon className="size-3.5 animate-spin text-blue-500" />;
case "pending": // 待领取(open intent)
return <ClockIcon className="size-3.5 text-muted-foreground" />;
case "done": // 完成
return <CircleCheckIcon className="size-3.5 text-emerald-500" />;
case "stopped": // 取消/停止(被 planner 终止)
return <CircleSlashIcon className="size-3.5 text-amber-500" />;
case "blocked": // 出错
return <CircleXIcon className="size-3.5 text-red-500" />;
case "exhausted": // 步数耗尽(撞 max_turns)
return <ZapOffIcon className="size-3.5 text-violet-500" />;
}
}
// fmtTokens renders a compact token count (1234 → 1.2k, 2_000_000 → 2M).
function fmtTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M";
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k";
return String(n);
}
// fmtDuration renders an elapsed milliseconds span compactly (90s → 1m30s).
function fmtDuration(ms: number): string {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m${String(s % 60).padStart(2, "0")}s`;
const h = Math.floor(m / 60);
return `${h}h${String(m % 60).padStart(2, "0")}m`;
}
const roleMeta = {
mainagent: { label: "主 Agent", icon: UserIcon },
planner: { label: "规划 Planner", icon: BrainIcon },
worker: { label: "Workers", icon: RadioIcon },
} as const;
// The main-agent session is the interactive entry point of this tab and has no
// dedicated backend "sessions" endpoint — it is a fixed UI affordance whose
// transcript is the full live activity stream for the task.
const MAIN_ID = "s-main";
const MAIN_SESSION: Session = {
id: MAIN_ID,
role: "mainagent",
title: "主 Agent · 编排会话",
status: "running",
live: true,
last_activity: "",
};
// The planner session is, like the main-agent session, a fixed UI affordance with
// no dedicated backend "sessions" endpoint — its transcript is every activity step
// the planner emits (worker === "planner", which carries no intent_id since the
// planner is what generates intents).
const PLANNER_ID = "s-planner";
const PLANNER_SESSION: Session = {
id: PLANNER_ID,
role: "planner",
title: "规划 Planner · 态势研判",
status: "running",
live: true,
last_activity: "",
};
// Map an exploration intent (TaskNode) state → a session status the UI renders.
function intentStatus(state: string): SessionStatus {
switch (state) {
case "done":
return "done";
case "blocked":
return "blocked";
case "exhausted":
return "exhausted";
case "stopped":
return "stopped";
case "open": // 待领取,区别于执行中
return "pending";
default: // running
return "running";
}
}
// Derive worker sessions from running/open intents — there is no backend
// sessions endpoint, so intents (≈ worker units) are the closest real source.
function intentToSession(n: TaskNode): Session {
const label = (n.payload ?? "").trim();
const state = intentStatus(n.state);
return {
id: n.id,
role: "worker",
title: label || `Intent ${n.id}`,
status: state,
live: state === "running",
last_activity: n.ts,
intent_id: n.id,
};
}
function SessionItem({
s,
active,
displayTitle,
hasPending,
onClick,
}: {
s: Session;
active: boolean;
displayTitle: string;
hasPending?: boolean;
onClick: () => void;
}) {
const icon =
s.role === "worker" ? (
statusIcon(s.status)
) : s.live ? (
<Loader2Icon className="size-3.5 animate-spin text-blue-500" />
) : null;
return (
<button
onClick={onClick}
className={cn(
"flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
)}
>
{icon ?? <span className="size-3.5 shrink-0" />}
{s.intent_id && (
<span className="shrink-0 rounded bg-muted px-1 py-0.5 font-mono text-[10px] tabular-nums text-muted-foreground">
#{s.intent_id}
</span>
)}
<span className="min-w-0 flex-1 truncate">{displayTitle}</span>
{hasPending && (
<ShieldAlertIcon className="size-3.5 shrink-0 text-amber-500" />
)}
{s.live && (
<span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
<span className="size-1 animate-pulse rounded-full bg-blue-500" />
实时
</span>
)}
</button>
);
}
export function SessionsTab({ taskId }: { taskId: string }) {
const [activeId, setActiveId] = React.useState(MAIN_ID);
// Live activity stream — the primary data for this tab.
const [allActivity, setAllActivity] = React.useState<Activity[]>([]);
// Worker sessions derived from exploration intents.
const [intents, setIntents] = React.useState<TaskNode[]>([]);
// Local-only chat messages overlaid onto the main-agent transcript.
const [chatExtra, setChatExtra] = React.useState<Activity[]>([]);
const [input, setInput] = React.useState("");
const [sending, setSending] = React.useState(false);
const [stopping, setStopping] = React.useState(false);
const [loaded, setLoaded] = React.useState(false);
// Whole-task token total (all agents), polled from the backend aggregate.
const [taskTokens, setTaskTokens] = React.useState<TokenTotal | null>(null);
// Pending intercept requests for this task — used to show warning icons on sessions.
const [pendingIntercepts, setPendingIntercepts] = React.useState<InterceptApprovalRow[]>([]);
React.useEffect(() => {
let alive = true;
const load = () =>
api
.tokenStats(taskId)
.then((r) => {
if (alive) setTaskTokens(r.total);
})
.catch(() => {});
load();
const t = setInterval(load, 5000);
return () => {
alive = false;
clearInterval(t);
};
}, [taskId]);
React.useEffect(() => {
let alive = true;
const load = () =>
api.interceptTask(taskId)
.then((rows) => { if (alive) setPendingIntercepts(rows.filter((r) => r.status === "pending")); })
.catch(() => {});
load();
const t = setInterval(load, 5000);
return () => { alive = false; clearInterval(t); };
}, [taskId]);
// Re-render on a timer so "streaming" liveness recomputes as activity goes stale.
const [, setTick] = React.useState(0);
React.useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 1500);
return () => clearInterval(id);
}, []);
// A session is "streaming" only if its agent emitted activity in the last few
// seconds (i.e. SSE actually in flight) — not a hardcoded always-on flag.
const STREAM_WINDOW_MS = 5000;
const streamingFor = React.useCallback(
(w: string) => {
let latest = 0;
for (const a of allActivity) {
if (a.worker === w) {
const t = Date.parse(a.ts);
if (t > latest) latest = t;
}
}
return latest > 0 && Date.now() - latest < STREAM_WINDOW_MS;
},
[allActivity],
);
const mainLive = streamingFor("mainagent");
const plannerLive = streamingFor("planner");
// Live activity stream via SSE: the endpoint replays history after since=0 then
// tails each new step. EventSource auto-reconnects; on reconnect history replays
// and the seq dedup below keeps it gap-free without duplicates.
React.useEffect(() => {
setAllActivity([]);
setLoaded(false);
// Mock demo:无 SSE 后端,一次性拉取活动快照(执行过程)。
if (MOCK) {
api
.activity(taskId)
.then((r) => setAllActivity(r.items))
.catch(() => {})
.finally(() => setLoaded(true));
return;
}
const es = new EventSource(
sseUrl(`/api/exploration/activity/stream?task=${encodeURIComponent(taskId)}&since=0`),
);
es.onopen = () => setLoaded(true);
es.onmessage = (e) => {
try {
const a = JSON.parse(e.data) as Activity;
setAllActivity((prev) =>
prev.some((x) => x.seq === a.seq)
? prev
: [...prev, a].sort((p, q) => p.seq - q.seq),
);
} catch {
/* ignore malformed frame */
}
};
return () => es.close();
}, [taskId]);
// Worker session list derives from exploration intents — poll lightly (changes
// far less often than the activity stream).
React.useEffect(() => {
let active = true;
const load = () =>
api.intents(taskId).then((nodes) => {
if (active) setIntents(nodes);
}).catch(() => {});
load();
const t = setInterval(load, 5000);
return () => {
active = false;
clearInterval(t);
};
}, [taskId]);
const workerSessions = React.useMemo(
() => intents.map(intentToSession),
[intents],
);
// For each worker session (intent), derive the display title from the latest
// activity summary (so the list shows what's actually happening, not the raw
// intent payload). Fall back to payload when no activity exists yet.
// Also store the full TaskNode for the hover-JSON tooltip.
const sessionMeta = React.useMemo(() => {
const map = new Map<string, { title: string; json: unknown }>();
for (const node of intents) {
let title = `Intent ${node.id}`;
let parsedPayload: unknown = node.payload;
if (node.payload) {
try {
const p = JSON.parse(node.payload);
parsedPayload = p;
if (p?.summary) title = String(p.summary);
} catch {
title = node.payload.trim() || title;
}
}
// Hover JSON: full node with payload replaced by parsed object for readability
const json = { ...node, payload: parsedPayload };
map.set(node.id, { title, json });
}
return map;
}, [intents]);
// Returns true if any pending intercept belongs to this session.
// Worker agent_name format: "work#N · #intentID". Main/planner match by role key.
const hasPendingForSession = React.useCallback(
(s: Session): boolean => {
if (!pendingIntercepts.length) return false;
if (s.role === "mainagent") return pendingIntercepts.some((r) => r.agent_name === "mainagent");
if (s.role === "planner") return pendingIntercepts.some((r) => r.agent_name === "planner");
// Worker: extract intent ID from "work#N · #<intentID>"
return pendingIntercepts.some((r) => {
const m = r.agent_name.match(/·\s*#(\d+)$/);
return m ? m[1] === s.intent_id : false;
});
},
[pendingIntercepts],
);
const sessions = React.useMemo(
() => [
{ ...MAIN_SESSION, live: mainLive },
{ ...PLANNER_SESSION, live: plannerLive },
...workerSessions,
],
[workerSessions, mainLive, plannerLive],
);
const grouped = {
mainagent: sessions.filter((s) => s.role === "mainagent"),
planner: sessions.filter((s) => s.role === "planner"),
worker: sessions.filter((s) => s.role === "worker"),
};
const active = sessions.find((s) => s.id === activeId) ?? MAIN_SESSION;
const isMain = active.role === "mainagent";
const isPlanner = active.role === "planner";
// Main agent is the human↔orchestrator CONSOLE: it shows only the conversation
// (the user's messages + the main agent's own replies/steps), used to steer the
// run — NOT the planner/worker activity firehose (those live in their own
// sessions). The planner session shows only planner steps; a worker session
// shows only the activity for its intent.
const activity = React.useMemo(() => {
if (isMain) {
// The conversation is persisted server-side in the activity stream
// (worker="mainagent"), so it survives reloads. chatExtra is only an
// optimistic local echo — drop any entry the persisted (SSE) copy already
// covers, so messages don't show twice once the backend round-trips.
const mine = allActivity.filter((a) => a.worker === "mainagent");
const seen = new Set(mine.map((a) => `${a.kind}${a.summary}`));
const pending = chatExtra.filter(
(a) => !seen.has(`${a.kind}${a.summary}`),
);
return [...mine, ...pending].sort((a, b) => a.seq - b.seq);
}
if (isPlanner) {
return allActivity.filter((a) => a.worker === "planner");
}
// Worker session: the intent is NOT shown as the header title — instead it
// leads the transcript as a right-aligned "user"-style message (the task
// handed to this worker), followed by its execution steps.
const steps = allActivity.filter(
(a) => a.intent_id === active.intent_id || a.worker === active.id,
);
const intentTitle = sessionMeta.get(active.id)?.title ?? active.title;
const intentMsg: Activity = {
seq: -1, // sorts/leads before any real step (real seq ≥ 0)
worker: steps[0]?.worker ?? active.id, // reuse the lane so no worker chips appear
ts: active.last_activity || "",
kind: "intent", // LLM-generated objective — rendered as a distinct (non-human) bubble
summary: intentTitle,
};
return [intentMsg, ...steps];
}, [isMain, isPlanner, allActivity, chatExtra, active.intent_id, active.id, active.title, active.last_activity, sessionMeta]);
// seq of this session's most-recent TodoWrite call — for the Todo popover shown in
// the worker/planner footer (in place of the old read-only replay notice).
const latestTodoSeq = React.useMemo(() => {
for (let i = activity.length - 1; i >= 0; i--) {
const a = activity[i];
if (a.kind === "tool_use" && a.tool === "TodoWrite") return a.seq;
}
return null;
}, [activity]);
// Per-session token total, live. Each run (one captureRunSession) reports
// cumulative usage per model turn via kind='usage', then a final kind='result'.
// Total = sum of COMPLETED runs' results + the current in-progress run's latest
// usage. A 'result' adds to the sum and clears the live partial; a 'usage'
// overwrites the live partial. So a running work counts up live; a finished one
// shows its final result. (activity is seq-sorted.)
const tokenTotal = React.useMemo(() => {
let i = 0,
o = 0,
cr = 0,
cw = 0; // completed
let li = 0,
lo = 0,
lcr = 0,
lcw = 0; // live in-progress
for (const a of activity) {
if (a.kind === "result") {
i += a.input_tokens ?? 0;
o += a.output_tokens ?? 0;
cr += a.cache_read_tokens ?? 0;
cw += a.cache_write_tokens ?? 0;
li = lo = lcr = lcw = 0; // run finished → drop its live partial
} else if (a.kind === "usage") {
li = a.input_tokens ?? 0;
lo = a.output_tokens ?? 0;
lcr = a.cache_read_tokens ?? 0;
lcw = a.cache_write_tokens ?? 0;
}
}
const I = i + li,
O = o + lo,
CR = cr + lcr,
CW = cw + lcw;
return { i: I, o: O, cr: CR, cw: CW, any: I + O + CR + CW > 0 };
}, [activity]);
// Run duration = span from this session's first step to its last (for a live
// session, "now" so it ticks up — the 1.5s setTick above re-renders it). Derived
// purely from activity timestamps (Activity.ts), the only per-step time we have.
const runDuration = React.useMemo(() => {
let min = Infinity,
max = 0;
for (const a of activity) {
const t = Date.parse(a.ts);
if (!Number.isFinite(t)) continue;
if (t < min) min = t;
if (t > max) max = t;
}
if (!Number.isFinite(min) || max === 0) return null;
const end = active.live ? Date.now() : max;
return Math.max(0, end - min);
}, [activity, active.live]);
// ---- transcript auto-scroll (open → bottom; stick to bottom unless scrolled up) ----
const contentRef = React.useRef<HTMLDivElement | null>(null);
const atBottomRef = React.useRef(true);
const viewport = React.useCallback(
() =>
(contentRef.current?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null) ??
null,
[],
);
React.useEffect(() => {
const vp = viewport();
if (!vp) return;
const onScroll = () => {
atBottomRef.current = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - 60;
};
vp.addEventListener("scroll", onScroll, { passive: true });
return () => vp.removeEventListener("scroll", onScroll);
}, [viewport, activeId]);
// open/switch a session → jump to the latest (bottom)
React.useLayoutEffect(() => {
const vp = viewport();
if (vp) {
vp.scrollTop = vp.scrollHeight;
atBottomRef.current = true;
}
}, [activeId, viewport]);
// new activity → stick to bottom only if the user is already pinned there
React.useLayoutEffect(() => {
if (!atBottomRef.current) return;
const vp = viewport();
if (vp) vp.scrollTop = vp.scrollHeight;
}, [activity, viewport]);
function stop() {
if (stopping) return;
setStopping(true);
api.stopChat(taskId).finally(() => setStopping(false));
}
function send() {
const text = input.trim();
if (!text || sending) return;
const base = [...allActivity, ...chatExtra];
const nextSeq = Math.max(0, ...base.map((a) => a.seq)) + 1;
const userMsg: Activity = {
seq: nextSeq,
worker: "mainagent",
ts: new Date().toISOString(),
kind: "user",
summary: text,
};
// optimistic echo of the human turn only; the agent's steps + final answer
// stream back live via SSE (worker="mainagent"), so we don't append the reply.
setChatExtra((prev) => [...prev, userMsg]);
setInput("");
setSending(true);
api
.chat(text, taskId)
.catch(() => {
setChatExtra((prev) => [
...prev,
{
seq: nextSeq + 1,
worker: "mainagent",
ts: new Date().toISOString(),
kind: "text",
summary: "(发送失败,请稍后重试)",
},
]);
})
.finally(() => setSending(false));
}
return (
<TooltipProvider delayDuration={300}>
<div className="grid h-[calc(100vh-13rem)] grid-cols-1 gap-4 lg:grid-cols-[18rem_1fr]">
{/* Left: session list */}
<div className="flex flex-col overflow-hidden rounded-lg border bg-card">
<div className="border-b px-3 py-2">
<div className="text-xs font-medium text-muted-foreground">会话列表</div>
{taskTokens && (
<div
className="mt-1 text-[11px] tabular-nums text-muted-foreground"
title="整个任务所有 agent 的 token 合计"
>
任务总计 · input {fmtTokens(taskTokens.input_tokens)} · cache{" "}
{fmtTokens(taskTokens.cache_read_tokens)} · output {fmtTokens(taskTokens.output_tokens)}
</div>
)}
</div>
<ScrollArea type="auto" className="min-h-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!">
<div className="flex w-full flex-col gap-3 p-2">
{(["mainagent", "planner", "worker"] as const).map((role) => {
const items = grouped[role];
if (!items.length) return null;
const Meta = roleMeta[role];
return (
<div key={role} className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5 px-2 py-1 text-xs font-medium text-muted-foreground">
<Meta.icon className="size-3.5" />
{Meta.label}
</div>
{items.map((s) => {
const meta = s.role === "worker" ? sessionMeta.get(s.id) : undefined;
return (
<SessionItem
key={s.id}
s={s}
active={s.id === activeId}
displayTitle={meta?.title ?? s.title}
hasPending={hasPendingForSession(s)}
onClick={() => setActiveId(s.id)}
/>
);
})}
</div>
);
})}
{loaded && !workerSessions.length && (
<div className="px-2 py-1 text-xs text-muted-foreground">
暂无运行中的 Worker 会话。
</div>
)}
</div>
</ScrollArea>
</div>
{/* Right: transcript */}
<div className="flex min-w-0 flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex items-center gap-2 border-b px-4 py-2.5">
{(() => {
const isWorker = active.role === "worker";
const meta = isWorker ? sessionMeta.get(active.id) : undefined;
// Worker: the intent moved into the transcript as a message, so the
// header shows a stable generic label (intent JSON stays on hover).
const title = isWorker ? "Worker 执行会话" : active.title;
const titleEl = <span className="text-sm font-medium">{title}</span>;
return meta?.json ? (
<Tooltip>
<TooltipTrigger asChild>{titleEl}</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-h-80 max-w-sm overflow-auto p-0">
<pre className="p-2 text-[10px] leading-relaxed">
{JSON.stringify(meta.json, null, 2)}
</pre>
</TooltipContent>
</Tooltip>
) : titleEl;
})()}
{active.live && (
<span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
<span className="size-1 animate-pulse rounded-full bg-blue-500" />
实时
</span>
)}
<div className="ml-auto flex items-center gap-3 text-xs text-muted-foreground">
{tokenTotal.any && (
<span title="input / cache(read) / output tokens">
input {fmtTokens(tokenTotal.i)} · cache {fmtTokens(tokenTotal.cr)} · output {fmtTokens(tokenTotal.o)}
</span>
)}
{runDuration != null && (
<span
className="inline-flex items-center gap-1"
title="运行时长(首步 → 末步)"
>
<ClockIcon className="size-3" />
{fmtDuration(runDuration)}
</span>
)}
{isMain && <span>可交互</span>}
</div>
</div>
{/* Force Radix's internal viewport wrapper (display:table, sizes to content)
to block so wide/unbreakable steps (long commands, code, URLs) can't blow
out the width and defeat the truncation below — the transcript wraps to
the panel instead of overflowing horizontally. */}
<ScrollArea
type="auto"
className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!"
>
<div className="min-w-0 max-w-full p-4" ref={contentRef}>
{!loaded ? (
<div className="flex items-center gap-2 pl-9 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
加载活动流…
</div>
) : activity.length ? (
<Transcript activity={activity} live={active.live} taskId={taskId} chat={isMain} />
) : (
<div className="pl-9 text-xs text-muted-foreground">
{isMain
? "还没有对话。在下方给主 Agent 发消息,引导探索方向或介入流程。"
: "暂无活动记录。"}
</div>
)}
</div>
</ScrollArea>
{isMain ? (
<div className="flex items-center gap-2 border-t p-3">
<Input
placeholder="给主 Agent 发消息,引导探索方向…"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !mainLive) send();
}}
disabled={mainLive}
/>
{mainLive ? (
<Button size="icon" variant="destructive" onClick={stop} disabled={stopping} title="停止当前执行">
{stopping ? <Loader2Icon className="animate-spin" /> : <SquareIcon />}
</Button>
) : (
<Button size="icon" onClick={send} disabled={!input.trim() || sending}>
{sending ? <Loader2Icon className="animate-spin" /> : <SendIcon />}
</Button>
)}
</div>
) : (
<div className="flex items-center border-t px-4 py-2">
<TodoPopover
seq={latestTodoSeq}
fetchDetail={(seq) => api.activityDetail(seq, taskId).then((r) => r.detail ?? "")}
/>
</div>
)}
</div>
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,200 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { toast } from "sonner";
import {
ArrowLeftIcon,
PauseIcon,
PlayIcon,
BrainIcon,
} from "lucide-react";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { StatusBadge } from "@/components/status-badge";
import { api } from "@/lib/api";
import type { Task } from "@/lib/types";
import { SessionsTab } from "./_tabs/sessions-tab";
import { OverviewTab } from "./_tabs/overview-tab";
import { GraphTab } from "./_tabs/graph-tab";
import { FindingsTab } from "./_tabs/findings-tab";
import { ReportTab } from "./_tabs/report-tab";
import { InterceptTab } from "./_tabs/intercept-tab";
import { AssetsTab } from "./_tabs/assets-tab";
const TABS = [
{ value: "sessions", label: "会话" },
{ value: "overview", label: "总览" },
{ value: "graph", label: "探索链路" },
{ value: "findings", label: "发现" },
{ value: "assets", label: "测试资产" },
{ value: "intercept", label: "拦截审批" },
{ value: "report", label: "报告" },
];
function TaskDetailInner() {
const searchParams = useSearchParams();
const id = searchParams.get("id") ?? "";
const [task, setTask] = React.useState<Task | null>(null);
const [paused, setPaused] = React.useState(false);
const [loaded, setLoaded] = React.useState(false);
const [tab, setTab] = React.useState("sessions");
const [interceptPendingCount, setInterceptPendingCount] = React.useState(0);
React.useEffect(() => {
let alive = true;
const load = () =>
api.interceptTask(id)
.then((rows) => { if (alive) setInterceptPendingCount(rows.filter((r) => r.status === "pending").length); })
.catch(() => {});
load();
const t = setInterval(load, 5000);
return () => { alive = false; clearInterval(t); };
}, [id]);
const load = React.useCallback(() => {
Promise.all([
api.tasks(),
api.stats(id).catch(() => null),
])
.then(([r, s]) => {
const base = r.tasks.find((t) => t.id === id) ?? null;
const at = (s as { active_task?: Partial<Task> & { paused?: boolean } } | null)?.active_task;
if (base && at) {
base.in_flight = at.in_flight;
base.goals_total = at.goals_total;
base.goals_met = at.goals_met;
base.engine_mode = at.engine_mode;
base.paused = at.paused;
}
setTask(base);
setPaused(at?.paused ?? base?.paused ?? false);
})
.catch(() => {})
.finally(() => setLoaded(true));
}, [id]);
React.useEffect(() => { load(); }, [load]);
async function togglePause() {
const next = !paused;
try {
await api.controlTask(id, next ? "pause" : "resume");
setPaused(next);
toast.success(next ? "已暂停探索" : "已恢复探索");
} catch (e) {
toast.error("操作失败:" + (e as Error).message);
}
}
if (!task) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-10 text-center">
<p className="text-muted-foreground">{loaded ? `未找到任务 ${id}` : "加载中…"}</p>
{loaded && (
<Button asChild variant="outline">
<Link href="/function/tasks">
<ArrowLeftIcon /> 返回任务列表
</Link>
</Button>
)}
</div>
);
}
const engineMode = paused ? "paused" : task.engine_mode ?? "idle";
return (
<Tabs
value={tab}
onValueChange={setTab}
className="flex flex-1 flex-col gap-0"
>
{/* Top fixed area */}
<header className="sticky top-0 z-10 flex flex-col gap-2 border-b bg-background/95 px-4 py-2.5 backdrop-blur lg:px-6">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Button asChild variant="ghost" size="icon" className="size-7">
<Link href="/function/tasks">
<ArrowLeftIcon />
</Link>
</Button>
<h1 className="max-w-md truncate text-sm font-semibold" title={task.description}>
{task.description}
</h1>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
{task.id}
</code>
<Separator orientation="vertical" className="mx-1 h-4" />
{/* status pills */}
<span className="inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs">
<BrainIcon className="size-3.5 text-emerald-500" /> LLM 已配置
</span>
<StatusBadge domain="engine" value={engineMode} dot />
<div className="ml-auto">
<Button
size="sm"
variant={paused ? "default" : "outline"}
onClick={togglePause}
>
{paused ? <PlayIcon /> : <PauseIcon />}
{paused ? "恢复" : "暂停"}
</Button>
</div>
</div>
<p className="truncate text-xs text-muted-foreground">{task.goal}</p>
{/* Tabs */}
<TabsList variant="default">
{TABS.map((t) => (
<TabsTrigger key={t.value} value={t.value}>
{t.label}
{t.value === "intercept" && interceptPendingCount > 0 && (
<span className="ml-1.5 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-semibold leading-none text-white">
{interceptPendingCount > 99 ? "99+" : interceptPendingCount}
</span>
)}
</TabsTrigger>
))}
</TabsList>
</header>
{/* Tab content */}
<div className="flex-1 p-4 lg:p-6">
<TabsContent value="sessions" className="mt-0">
<SessionsTab taskId={id} />
</TabsContent>
<TabsContent value="overview" className="mt-0">
<OverviewTab taskId={id} />
</TabsContent>
<TabsContent value="graph" className="mt-0">
<GraphTab taskId={id} />
</TabsContent>
<TabsContent value="findings" className="mt-0">
<FindingsTab taskId={id} />
</TabsContent>
<TabsContent value="assets" className="mt-0">
<AssetsTab taskId={id} />
</TabsContent>
<TabsContent value="intercept" className="mt-0">
<InterceptTab taskId={id} />
</TabsContent>
<TabsContent value="report" className="mt-0">
<ReportTab taskId={id} />
</TabsContent>
</div>
</Tabs>
);
}
// useSearchParams must sit under a Suspense boundary for static export.
export default function TaskDetailPage() {
return (
<React.Suspense fallback={null}>
<TaskDetailInner />
</React.Suspense>
);
}
+428
View File
@@ -0,0 +1,428 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { toast } from "sonner";
import {
PlusIcon,
Trash2Icon,
ArrowRightIcon,
StarIcon,
SearchIcon,
XIcon,
} from "lucide-react";
import { StatusBadge } from "@/components/status-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TablePagination } from "@/components/table-pagination";
import { api } from "@/lib/api";
import type { Task, TaskStatus, LLMProfile } from "@/lib/types";
// ACTIVE_PROFILE is the sentinel Select value for "use the global active profile".
const ACTIVE_PROFILE = "__active__";
// fmtTokens renders a compact token count (1234 → 1.2k, 2_000_000 → 2M).
function fmtTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M";
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k";
return String(n);
}
// fmtDuration renders a run duration in seconds as a compact human string.
function fmtDuration(sec: number): string {
if (sec <= 0) return "—";
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
// taskDuration is the task's run duration in seconds: created → now while running,
// created → completion for a finished task, else created → last activity. 0 when it
// never ran (no activity yet).
function taskDuration(task: Task, nowSec: number): number {
const start = task.created_unix ?? 0;
if (!start) return 0;
const end =
task.status === "running"
? nowSec
: task.completed_unix && task.completed_unix > 0
? task.completed_unix
: task.last_activity_unix ?? 0;
return end > start ? end - start : 0;
}
// fmtDateTime renders a unix-seconds timestamp as a compact local date-time
// (MM-DD HH:mm), or "—" when unset.
function fmtDateTime(unix?: number): string {
if (!unix || unix <= 0) return "—";
const d = new Date(unix * 1000);
const p = (n: number) => String(n).padStart(2, "0");
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
const STATUS_OPTIONS: { value: TaskStatus; label: string }[] = [
{ value: "created", label: "已创建" },
{ value: "running", label: "运行中" },
{ value: "paused", label: "已暂停" },
{ value: "done", label: "已完成" },
{ value: "failed", label: "失败" },
{ value: "timeout", label: "已超时" },
];
export default function TasksPage() {
const [tasks, setTasks] = React.useState<Task[]>([]);
const [description, setDescription] = React.useState("");
const [goal, setGoal] = React.useState("");
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
const [llmProfile, setLlmProfile] = React.useState<string>(ACTIVE_PROFILE); // sentinel = active
const [timeoutMin, setTimeoutMin] = React.useState(""); // 任务级超时(分钟);空/0 = 不限时
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState<TaskStatus | "all">("all");
const [page, setPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(20);
const [nowSec, setNowSec] = React.useState(() => Math.floor(Date.now() / 1000));
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
return tasks.filter((t) => {
if (statusFilter !== "all" && t.status !== statusFilter) return false;
if (!q) return true;
return (
t.description.toLowerCase().includes(q) ||
t.goal.toLowerCase().includes(q) ||
t.id.toLowerCase().includes(q)
);
});
}, [tasks, query, statusFilter]);
// reset to page 1 whenever filters change
React.useEffect(() => { setPage(1); }, [query, statusFilter]);
const paginated = React.useMemo(
() => filtered.slice((page - 1) * pageSize, page * pageSize),
[filtered, page, pageSize],
);
const load = React.useCallback(() => {
api.tasks()
.then((r) => {
setTasks(r.tasks.map((t) => (t.id === r.active ? { ...t, active: true } : t)));
})
.catch(() => {});
}, []);
React.useEffect(() => {
load();
const i = setInterval(load, 3000);
return () => clearInterval(i);
}, [load]);
// load LLM profiles once for the create-task profile picker.
React.useEffect(() => {
api.llmProfiles().then(setProfiles).catch(() => setProfiles([]));
}, []);
// tick every second so running tasks' 运行时长 counts up live.
React.useEffect(() => {
const i = setInterval(() => setNowSec(Math.floor(Date.now() / 1000)), 1000);
return () => clearInterval(i);
}, []);
async function createTask() {
if (!description.trim() || !goal.trim()) {
toast.error("请填写描述与目标");
return;
}
try {
const pid = llmProfile === ACTIVE_PROFILE ? undefined : Number(llmProfile);
const timeoutSec = Math.max(0, Math.floor(Number(timeoutMin) || 0)) * 60;
await api.createTask(description.trim(), goal.trim(), pid, timeoutSec);
toast.success("任务已创建");
setDescription("");
setGoal("");
setLlmProfile(ACTIVE_PROFILE);
setTimeoutMin("");
setOpen(false);
load();
} catch (e) {
toast.error("创建失败:" + (e as Error).message);
}
}
async function deleteTask(id: string) {
try {
await api.deleteTask(id);
toast.success("任务已删除(全局资产图保留)");
load();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}
return (
<Card>
<CardContent className="flex flex-col gap-4 px-0 pt-6">
<div className="flex flex-wrap items-center gap-2 px-4 lg:px-6">
<div className="relative w-full sm:max-w-xs">
<SearchIcon className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
<Input
placeholder="搜索描述 / 目标 / ID"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-8"
/>
{query && (
<button
type="button"
onClick={() => setQuery("")}
aria-label="清除搜索"
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-2 -translate-y-1/2"
>
<XIcon className="size-4" />
</button>
)}
</div>
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as TaskStatus | "all")}>
<SelectTrigger className="w-36">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
{STATUS_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-xs tabular-nums">
{filtered.length}/{tasks.length} 条
</span>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" className="ml-auto">
<PlusIcon /> 新建任务
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>新建任务</DialogTitle>
<DialogDescription>
填写测试对象与目标。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="description">描述</Label>
<Textarea
id="description"
placeholder="测试对象与背景,例如:测试 example.com 这个站点"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="goal">目标</Label>
<Textarea
id="goal"
placeholder="要达成什么,例如:拿下后台管理权限、获取服务器权限"
value={goal}
onChange={(e) => setGoal(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="llm-profile">LLM 配置</Label>
<Select value={llmProfile} onValueChange={setLlmProfile}>
<SelectTrigger id="llm-profile">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ACTIVE_PROFILE}>使用激活配置(默认)</SelectItem>
{profiles.map((p) => (
<SelectItem key={p.id} value={String(p.id)}>
{p.name}
{p.is_default ? "(激活)" : ""} · {p.model}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
本任务的 planner/worker 使用所选配置运行;对话仍用激活配置。
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="timeout-min">任务超时(分钟,可选)</Label>
<Input
id="timeout-min"
type="number"
min={0}
className="w-40"
placeholder="留空 = 不限时"
value={timeoutMin}
onChange={(e) => setTimeoutMin(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
到点后触发优雅收尾(各 agent 写回 + planner 终局判定),任务进入 timeout 终态。
</p>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">取消</Button>
</DialogClose>
<Button onClick={createTask}>创建</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{tasks.length === 0 ? (
<div className="text-muted-foreground mx-4 flex items-center justify-center rounded-lg border border-dashed py-20 text-sm lg:mx-6">
暂无任务,点击右上角「新建任务」开始。
</div>
) : filtered.length === 0 ? (
<div className="text-muted-foreground mx-4 flex items-center justify-center rounded-lg border border-dashed py-20 text-sm lg:mx-6">
没有匹配的任务。
</div>
) : (
<Table className="**:data-[slot='table-cell']:px-4 **:data-[slot='table-head']:px-4">
<TableHeader className="[&_tr]:border-t">
<TableRow>
<TableHead className="font-mono">ID</TableHead>
<TableHead>描述</TableHead>
<TableHead>目标</TableHead>
<TableHead>状态</TableHead>
<TableHead className="text-center">目标进度</TableHead>
<TableHead className="text-right">创建时间</TableHead>
<TableHead className="text-right">运行时长</TableHead>
<TableHead className="text-right">Token</TableHead>
<TableHead className="sticky right-0 z-10 bg-card text-right shadow-[-1px_0_0_0_hsl(var(--border))]">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginated.map((task) => (
<TableRow key={task.id} className="group border-border/60">
<TableCell>
<code className="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">{task.id}</code>
</TableCell>
<TableCell className="font-medium">
<div className="flex max-w-xs items-center gap-2">
<span className="truncate" title={task.description}>
{task.description}
</span>
{task.active && (
<StarIcon className="size-4 shrink-0 fill-amber-400 text-amber-400" />
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground max-w-xs truncate">{task.goal}</TableCell>
<TableCell>
<StatusBadge domain="task" value={task.status} dot />
</TableCell>
<TableCell className="text-muted-foreground text-center text-xs tabular-nums">
{typeof task.goals_total === "number" && task.goals_total > 0
? `${task.goals_met}/${task.goals_total}`
: <span className="text-muted-foreground">—</span>}
</TableCell>
<TableCell className="text-muted-foreground text-right text-xs whitespace-nowrap tabular-nums">
{fmtDateTime(task.created_unix)}
</TableCell>
<TableCell className="text-right text-xs whitespace-nowrap tabular-nums">
{(() => {
const secs = taskDuration(task, nowSec);
if (secs <= 0) return <span className="text-muted-foreground">—</span>;
return (
<span className={task.status === "running" ? "text-foreground" : "text-muted-foreground"}>
{fmtDuration(secs)}
</span>
);
})()}
</TableCell>
<TableCell
className="text-right text-xs whitespace-nowrap tabular-nums"
title={
task.tokens
? `输入 ${task.tokens.input_tokens} · 缓存 ${task.tokens.cache_read_tokens} · 输出 ${task.tokens.output_tokens}`
: undefined
}
>
{task.tokens ? (
<span className="text-muted-foreground">
入 <span className="text-foreground">{fmtTokens(task.tokens.input_tokens)}</span>
{" · 缓 "}
<span className="text-foreground">{fmtTokens(task.tokens.cache_read_tokens)}</span>
{" · 出 "}
<span className="text-foreground">{fmtTokens(task.tokens.output_tokens)}</span>
</span>
) : (
"—"
)}
</TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-right shadow-[-1px_0_0_0_hsl(var(--border))] group-hover:bg-muted/50">
<div className="flex items-center justify-end gap-2">
<Button asChild size="sm">
<Link href={`/function/tasks/detail?id=${task.id}`}>
进入 <ArrowRightIcon />
</Link>
</Button>
<Button
size="icon"
variant="outline"
onClick={() => deleteTask(task.id)}
aria-label="删除任务"
>
<Trash2Icon className="text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<TablePagination
page={page}
pageSize={pageSize}
total={filtered.length}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
</CardContent>
</Card>
);
}
@@ -0,0 +1,383 @@
"use client";
import * as React from "react";
import {
RadioTowerIcon,
SearchIcon,
ChevronLeftIcon,
ChevronRightIcon,
XIcon,
Loader2Icon,
} from "lucide-react";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
import type { TrafficExchange, TrafficResp, TrafficDetail } from "@/lib/types";
function fmtTime(ts: string) {
return new Date(ts).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function fmtBytes(n: number) {
if (n <= 0) return "0 B";
const units = ["B", "KB", "MB"];
const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1);
const v = n / Math.pow(1024, i);
return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`;
}
function statusTone(status: number) {
if (status >= 500) return "text-red-500";
if (status >= 400) return "text-amber-500";
if (status >= 300) return "text-blue-500";
if (status >= 200) return "text-emerald-500";
return "text-muted-foreground";
}
// Fixed method set (server filters exact-match); avoids deriving options from a
// single page, which would only ever list the methods on that page.
const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
const PAGE_SIZES = [25, 50, 100, 200];
export default function TrafficPage() {
const [page, setPage] = React.useState(0);
const [size, setSize] = React.useState(50);
const [host, setHost] = React.useState(""); // raw host input
const [hostQ, setHostQ] = React.useState(""); // debounced → server
const [query, setQuery] = React.useState(""); // raw free-text input
const [queryQ, setQueryQ] = React.useState(""); // debounced → server
const [method, setMethod] = React.useState("all");
const [traffic, setTraffic] = React.useState<TrafficResp | null>(null);
const [selected, setSelected] = React.useState<TrafficExchange | null>(null);
const [detail, setDetail] = React.useState<TrafficDetail | null>(null);
const [detailLoading, setDetailLoading] = React.useState(false);
// Debounce both filters so we don't refetch on every keystroke.
React.useEffect(() => {
const t = setTimeout(() => setHostQ(host.trim()), 300);
return () => clearTimeout(t);
}, [host]);
React.useEffect(() => {
const t = setTimeout(() => setQueryQ(query.trim()), 300);
return () => clearTimeout(t);
}, [query]);
// Any filter/size change resets to the first page.
React.useEffect(() => {
setPage(0);
}, [hostQ, queryQ, method, size]);
// Load the current page. Auto-refresh only on page 0 (newest) so paging back
// through history isn't yanked out from under the user.
React.useEffect(() => {
let alive = true;
const load = () =>
api
.traffic(page, size, hostQ, method, queryQ)
.then((r) => {
if (alive) setTraffic(r);
})
.catch(() => {});
load();
const t = page === 0 ? setInterval(load, 5000) : null;
return () => {
alive = false;
if (t) clearInterval(t);
};
}, [page, size, hostQ, method, queryQ]);
// Lazy-load the raw request/response for the selected exchange.
React.useEffect(() => {
if (!selected) {
setDetail(null);
return;
}
let alive = true;
setDetailLoading(true);
setDetail(null);
api
.trafficExchange(selected.id)
.then((d) => {
if (alive) setDetail(d);
})
.catch(() => {
if (alive) setDetail({ req: "(无法加载报文)", resp: "" });
})
.finally(() => {
if (alive) setDetailLoading(false);
});
return () => {
alive = false;
};
}, [selected]);
const exchanges = React.useMemo(() => traffic?.exchanges ?? [], [traffic]);
const total = traffic?.total ?? exchanges.length;
const pageCount = Math.max(1, Math.ceil(total / size));
const rangeStart = total === 0 ? 0 : page * size + 1;
const rangeEnd = page * size + exchanges.length;
return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold tracking-tight">流量</h1>
<p className="text-muted-foreground text-sm">
全局录制代理 · 所有 HTTP 往来
</p>
</div>
<div className="flex items-center gap-4 text-sm">
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium",
traffic?.enabled
? "border-emerald-500/20 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
: "border-transparent bg-muted text-muted-foreground",
)}
>
<RadioTowerIcon className="size-3.5" />
{traffic?.enabled ? "录制中" : "已停用"}
</span>
{traffic?.proxy && (
<span className="font-mono text-xs text-muted-foreground">
{traffic.proxy}
</span>
)}
<span className="text-xs text-muted-foreground">
共 <span className="tabular-nums">{traffic?.count ?? 0}</span> 条
</span>
</div>
</div>
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-2">
<div className="relative w-48">
<Input
placeholder="host…"
value={host}
onChange={(e) => setHost(e.target.value)}
className="h-8"
/>
</div>
<div className="relative max-w-sm flex-1">
<SearchIcon className="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索全部(URL / 方法 / 类型 / 状态码…)"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-8 pl-8"
/>
</div>
<Select value={method} onValueChange={setMethod}>
<SelectTrigger size="sm" className="w-32">
<SelectValue placeholder="方法" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部方法</SelectItem>
{METHODS.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={String(size)} onValueChange={(v) => setSize(Number(v))}>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((n) => (
<SelectItem key={n} value={String(n)}>
{n} / 页
</SelectItem>
))}
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
<span className="tabular-nums">
{rangeStart}–{rangeEnd} / {total}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<ChevronLeftIcon />
</Button>
<span className="tabular-nums">
{page + 1} / {pageCount}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page + 1 >= pageCount}
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
>
<ChevronRightIcon />
</Button>
</div>
</div>
{/* History table + detail (Burp-style split) */}
<div className="flex h-[calc(100vh-15rem)] min-h-0 flex-col gap-3">
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
<div className="min-h-0 flex-1 overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead className="w-36">时间</TableHead>
<TableHead className="w-44">host</TableHead>
<TableHead className="w-20">方法</TableHead>
<TableHead>URL</TableHead>
<TableHead className="w-20">状态码</TableHead>
<TableHead className="w-36">content-type</TableHead>
<TableHead className="w-24 text-right">响应长度</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{exchanges.map((e) => (
<TableRow
key={e.id}
className={cn(
"cursor-pointer",
selected?.id === e.id && "bg-accent hover:bg-accent",
)}
onClick={() => setSelected(e)}
>
<TableCell className="text-xs text-muted-foreground tabular-nums">
{fmtTime(e.ts)}
</TableCell>
<TableCell className="font-mono text-xs">{e.host}</TableCell>
<TableCell>
<Badge variant="outline" className="font-mono text-xs">
{e.method}
</Badge>
</TableCell>
<TableCell className="max-w-0">
<span className="block truncate font-mono text-xs">
{e.url}
</span>
</TableCell>
<TableCell>
<span
className={cn(
"font-mono text-xs font-semibold tabular-nums",
statusTone(e.status),
)}
>
{e.status}
</span>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{e.content_type}
</TableCell>
<TableCell className="text-right text-xs tabular-nums">
{fmtBytes(e.resp_len)}
</TableCell>
</TableRow>
))}
{exchanges.length === 0 && (
<TableRow>
<TableCell
colSpan={7}
className="py-12 text-center text-sm text-muted-foreground"
>
{traffic === null ? "加载中…" : "没有匹配的流量。"}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</Card>
{/* Raw request/response pane */}
{selected && (
<Card className="flex h-[42%] min-h-0 flex-col overflow-hidden py-0">
<div className="flex items-center gap-2 border-b px-3 py-2">
<Badge variant="outline" className="font-mono text-xs">
{selected.method}
</Badge>
<span className="truncate font-mono text-xs">
{selected.host}
{selected.url}
</span>
<span
className={cn(
"ml-auto font-mono text-xs font-semibold tabular-nums",
statusTone(selected.status),
)}
>
{selected.status}
</span>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
onClick={() => setSelected(null)}
>
<XIcon />
</Button>
</div>
<div className="grid min-h-0 flex-1 grid-cols-2 divide-x">
{(
[
["请求 Request", detail?.req],
["响应 Response", detail?.resp],
] as const
).map(([label, body]) => (
<div key={label} className="flex min-h-0 min-w-0 flex-col">
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground">
{label}
</div>
<div className="min-h-0 flex-1 overflow-auto">
{detailLoading ? (
<div className="flex items-center gap-2 p-3 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
加载报文…
</div>
) : (
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
{body || "(空)"}
</pre>
)}
</div>
</div>
))}
</div>
</Card>
)}
</div>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import type { ReactNode } from "react";
import * as React from "react";
import { AppSidebar } from "@/app/(main)/_components/sidebar/app-sidebar";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { auth } from "@/lib/auth";
import { getClientCookie } from "@/lib/cookie.client";
import {
SIDEBAR_COLLAPSIBLE_VALUES,
SIDEBAR_VARIANT_VALUES,
type SidebarCollapsible,
type SidebarVariant,
} from "@/lib/preferences/layout";
import { PREFERENCE_DEFAULTS } from "@/lib/preferences/preferences-config";
import { cn } from "@/lib/utils";
import { MainContent } from "./_components/main-content";
// Reads a layout-critical preference from the browser cookie (falls back to the
// default during static-export prerender where document is unavailable). Kept
// fully client-side so the app can be statically exported — no Server Actions /
// next/headers.
function readPref<T extends string>(key: string, allowed: readonly T[], fallback: T): T {
if (typeof document === "undefined") return fallback;
const value = getClientCookie(key);
return value && (allowed as readonly string[]).includes(value) ? (value as T) : fallback;
}
export default function Layout({ children }: Readonly<{ children: ReactNode }>) {
// Client-side auth gate — replaces the Next proxy/middleware that static export
// disables. No token → bounce to /login; render nothing until confirmed so no
// protected UI (or its API calls) flashes for a logged-out visitor.
const [authed, setAuthed] = React.useState(false);
React.useEffect(() => {
if (auth.getToken()) {
setAuthed(true);
} else {
window.location.href = "/login";
}
}, []);
const defaultOpen = typeof document === "undefined" ? true : getClientCookie("sidebar_state") !== "false";
const variant = readPref<SidebarVariant>(
"sidebar_variant",
SIDEBAR_VARIANT_VALUES,
PREFERENCE_DEFAULTS.sidebar_variant,
);
const collapsible = readPref<SidebarCollapsible>(
"sidebar_collapsible",
SIDEBAR_COLLAPSIBLE_VALUES,
PREFERENCE_DEFAULTS.sidebar_collapsible,
);
if (!authed) return null;
return (
<SidebarProvider
defaultOpen={defaultOpen}
style={
{
"--sidebar-width": "calc(var(--spacing) * 68)",
} as React.CSSProperties
}
>
<AppSidebar variant={variant} collapsible={collapsible} />
<SidebarInset
className={cn(
"[html[data-content-layout=centered]_&>*]:mx-auto",
"[html[data-content-layout=centered]_&>*]:w-full",
"[html[data-content-layout=centered]_&>*]:max-w-screen-2xl",
"peer-data-[variant=inset]:border",
"[--dashboard-header-height:--spacing(12)]",
"min-w-0 overflow-x-hidden",
)}
>
<MainContent>{children}</MainContent>
</SidebarInset>
</SidebarProvider>
);
}
@@ -0,0 +1,47 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { ArrowLeftIcon } from "lucide-react";
import { AgentEditor } from "@/components/agent-editor";
import { Button } from "@/components/ui/button";
// Deep-link editor page for one agent. The primary flow is the drawer on
// /system/agents; this page reuses the same AgentEditor component full-width so a
// direct URL (or an external link) still opens the editor.
function AgentDetailInner() {
const searchParams = useSearchParams();
const key = searchParams.get("key") ?? "";
return (
<div className="flex flex-1 flex-col gap-4">
<div className="flex items-center gap-2">
<Button asChild variant="ghost" size="icon-sm">
<Link href="/system/agents">
<ArrowLeftIcon className="size-4" />
</Link>
</Button>
<div>
<h1 className="text-xl font-semibold tracking-tight">Agent</h1>
<p className="text-muted-foreground font-mono text-xs">{key}</p>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col rounded-lg border">
<AgentEditor agentKey={key} />
</div>
</div>
);
}
// useSearchParams must sit under a Suspense boundary for static export.
export default function AgentDetailPage() {
return (
<React.Suspense fallback={null}>
<AgentDetailInner />
</React.Suspense>
);
}
+283
View File
@@ -0,0 +1,283 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import { Bot, PlusIcon, Trash2Icon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { AgentEditor } from "@/components/agent-editor";
import { api } from "@/lib/api";
import type { Agent } from "@/lib/types";
// AgentGridCard is one clickable tile opening the agent's editor drawer. Custom
// (non-builtin) agents get a delete button.
function AgentGridCard({
agent,
onOpen,
onDeleted,
}: {
agent: Agent;
onOpen: () => void;
onDeleted: () => void;
}) {
async function del() {
try {
await api.deleteAgent(agent.key);
toast.success(`已删除 Agent「${agent.name}」`);
onDeleted();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}
return (
<div className="hover:border-primary/50 group relative flex flex-col gap-2 rounded-lg border p-4 transition-colors">
<button type="button" onClick={onOpen} className="flex flex-col gap-2 text-left">
<div className="flex flex-wrap items-center gap-2">
<Bot className="text-muted-foreground size-4" />
<span className="text-sm font-medium">{agent.name}</span>
<span className="text-muted-foreground font-mono text-xs">{agent.key}</span>
{agent.builtin ? (
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
内置
</Badge>
) : (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
自定义
</Badge>
)}
{!agent.enabled && (
<Badge variant="outline" className="text-destructive px-1.5 py-0 text-[10px]">
已停用
</Badge>
)}
</div>
<p className="text-muted-foreground line-clamp-2 min-h-8 text-xs">
{agent.description || "(无描述)"}
</p>
<div className="text-muted-foreground flex flex-wrap gap-1.5 text-[10px]">
<span className="rounded border px-1.5 py-0.5">MCP {agent.mcp_count ?? 0}</span>
<span className="rounded border px-1.5 py-0.5">Skill {agent.skill_count ?? 0}</span>
<span className="rounded border px-1.5 py-0.5">工具 {agent.tool_count ?? 0}</span>
</div>
</button>
{!agent.builtin && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-destructive absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100"
>
<Trash2Icon className="size-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除 Agent「{agent.name}」?</AlertDialogTitle>
<AlertDialogDescription>
将一并删除它的提示词、变量、可见性与工具绑定。此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={del}>删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
);
}
function CreateAgentDialog({ onCreated }: { onCreated: (key: string) => void }) {
const [open, setOpen] = React.useState(false);
const [key, setKey] = React.useState("");
const [name, setName] = React.useState("");
const [description, setDescription] = React.useState("");
const [busy, setBusy] = React.useState(false);
async function create() {
setBusy(true);
try {
const a = await api.createAgent(key.trim(), name.trim(), description.trim());
toast.success(`已创建 Agent「${a.name}」`);
setOpen(false);
setKey("");
setName("");
setDescription("");
onCreated(a.key);
} catch (e) {
toast.error("创建失败:" + (e as Error).message);
} finally {
setBusy(false);
}
}
const keyOk = /^[a-z][a-z0-9_]*$/.test(key.trim());
const canCreate = keyOk && name.trim().length > 0 && !busy;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm">
<PlusIcon /> 新建 Agent
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>新建自定义 Agent</DialogTitle>
<DialogDescription>
创建一个会话型助手。key 用于内部标识,创建后不可更改;名称与描述用于识别。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-1.5">
<Label htmlFor="agent-key">Key</Label>
<Input
id="agent-key"
placeholder="如 research_helper"
value={key}
onChange={(e) => setKey(e.target.value)}
className="font-mono"
/>
{key.length > 0 && !keyOk && (
<span className="text-destructive text-xs">小写字母开头,仅含小写字母/数字/下划线</span>
)}
</div>
<div className="grid gap-1.5">
<Label htmlFor="agent-name">名称</Label>
<Input
id="agent-name"
placeholder="如 研究助手"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="agent-desc">描述</Label>
<Textarea
id="agent-desc"
placeholder="一句话说明这个 Agent 是干什么的"
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button onClick={create} disabled={!canCreate}>
创建
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function AgentsPage() {
const [agents, setAgents] = React.useState<Agent[]>([]);
const [editKey, setEditKey] = React.useState<string | null>(null);
const reload = React.useCallback(() => {
api.agents().then(setAgents).catch(() => setAgents([]));
}, []);
React.useEffect(() => {
reload();
}, [reload]);
const editing = agents.find((a) => a.key === editKey) ?? null;
return (
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div className="flex items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold tracking-tight">Agent</h1>
<p className="text-muted-foreground text-sm">
内置 Agent 的提示词/配置,以及自定义会话 Agent 的创建与管理
</p>
</div>
<CreateAgentDialog
onCreated={(key) => {
reload();
setEditKey(key);
}}
/>
</div>
<Card>
<CardHeader>
<CardTitle>Agent 清单</CardTitle>
<CardDescription>共 {agents.length} 个</CardDescription>
</CardHeader>
<CardContent>
{agents.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">(暂无 Agent)</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{agents.map((a) => (
<AgentGridCard key={a.key} agent={a} onOpen={() => setEditKey(a.key)} onDeleted={reload} />
))}
</div>
)}
</CardContent>
</Card>
<Sheet open={!!editing} onOpenChange={(o) => !o && setEditKey(null)}>
<SheetContent
side="right"
className="flex flex-col gap-0 p-0 data-[side=right]:w-[45vw] data-[side=right]:sm:max-w-[45vw]"
>
{editing && (
<>
<SheetHeader className="px-4">
<SheetTitle className="flex items-center gap-2">
{editing.name}
<span className="text-muted-foreground font-mono text-xs">{editing.key}</span>
{!editing.builtin && (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
自定义
</Badge>
)}
</SheetTitle>
<SheetDescription>{editing.description || "提示词、配置、可见资源与工具绑定"}</SheetDescription>
</SheetHeader>
<AgentEditor agentKey={editing.key} onSaved={reload} />
</>
)}
</SheetContent>
</Sheet>
</div>
);
}
@@ -0,0 +1,244 @@
"use client";
import * as React from "react";
import { CheckIcon, ClipboardListIcon, RefreshCwIcon, ShieldAlertIcon, XIcon } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import type { InterceptApprovalRow } from "@/lib/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
// ---- helpers ----------------------------------------------------------------
function fmtTime(s: string) {
if (!s) return "—";
const d = new Date(s);
return d.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
function SourceCell({ row }: { row: InterceptApprovalRow }) {
if (row.task_id) {
return (
<div className="space-y-0.5">
<div className="font-mono text-xs font-medium">{row.task_id}</div>
<div className="text-[11px] text-muted-foreground">{row.agent_name || "—"}</div>
</div>
);
}
if (row.conversation_id) {
const agent = row.conv_agent_key || "—";
const title = row.conv_title || `对话 #${row.conversation_id}`;
return (
<div className="space-y-0.5">
<div className="font-medium">{title}</div>
<div className="text-[11px] text-muted-foreground">Agent: {agent}</div>
</div>
);
}
return <span className="text-muted-foreground">—</span>;
}
function StatusBadge({ status }: { status: string }) {
if (status === "pending") return <Badge variant="outline" className="border-amber-400 text-amber-600">待审批</Badge>;
if (status === "allowed") return <Badge variant="outline" className="border-emerald-500 text-emerald-600">已允许</Badge>;
if (status === "denied") return <Badge variant="outline" className="border-red-500 text-red-600">已拒绝</Badge>;
return <Badge variant="outline" className="text-muted-foreground">已超时</Badge>;
}
function InputPreview({ input }: { input: Record<string, unknown> }) {
const full = JSON.stringify(input, null, 2);
const s = JSON.stringify(input);
const short = s.length > 120 ? s.slice(0, 120) + "…" : s;
return (
<Tooltip>
<TooltipTrigger asChild>
<code className="block w-full cursor-default truncate font-mono text-[11px] text-muted-foreground">
{short}
</code>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-sm">
<pre className="whitespace-pre-wrap break-all font-mono text-xs">{full}</pre>
</TooltipContent>
</Tooltip>
);
}
// ---- main component ---------------------------------------------------------
export default function ApprovalsPage() {
const [rows, setRows] = React.useState<InterceptApprovalRow[]>([]);
const [loading, setLoading] = React.useState(true);
const [deciding, setDeciding] = React.useState<number | null>(null);
async function load() {
try {
const items = await api.interceptHistory();
setRows(items);
} catch (e) {
toast.error((e as Error).message);
} finally {
setLoading(false);
}
}
React.useEffect(() => {
load();
const t = setInterval(load, 5000);
return () => clearInterval(t);
}, []);
async function decide(id: number, decision: "allowed" | "denied") {
if (deciding !== null) return;
setDeciding(id);
try {
await api.interceptDecide(id, decision);
toast.success(decision === "allowed" ? "已允许执行" : "已拒绝执行");
setRows((prev) =>
prev.map((r) =>
r.id === id ? { ...r, status: decision, decided_at: new Date().toISOString() } : r,
),
);
} catch (e) {
toast.error((e as Error).message);
} finally {
setDeciding(null);
}
}
const pending = rows.filter((r) => r.status === "pending");
const decided = rows.filter((r) => r.status !== "pending");
return (
<TooltipProvider delayDuration={300}>
<div className="flex flex-1 flex-col gap-6 p-6">
{/* header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ClipboardListIcon className="h-5 w-5 text-muted-foreground" />
<h1 className="text-xl font-semibold">审批记录</h1>
{pending.length > 0 && (
<Badge className="rounded-full bg-destructive text-destructive-foreground">{pending.length} 待审批</Badge>
)}
</div>
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
<RefreshCwIcon className="mr-1.5 h-3.5 w-3.5" />
刷新
</Button>
</div>
{/* pending section */}
{pending.length > 0 && (
<section className="rounded-lg border border-amber-400/40 bg-amber-50/30 dark:bg-amber-950/10">
<div className="flex items-center gap-2 border-b border-amber-400/30 px-4 py-3 text-sm font-medium text-amber-700 dark:text-amber-400">
<ShieldAlertIcon className="h-4 w-4" />
待处理 ({pending.length})
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>工具</TableHead>
<TableHead>来源</TableHead>
<TableHead>匹配规则</TableHead>
<TableHead className="w-48">参数</TableHead>
<TableHead>时间</TableHead>
<TableHead className="w-28 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pending.map((row) => (
<TableRow key={row.id}>
<TableCell className="text-muted-foreground">{row.id}</TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
</TableCell>
<TableCell><SourceCell row={row} /></TableCell>
<TableCell>
{row.rule_name
? <span className="text-sm">{row.rule_name}</span>
: <span className="text-muted-foreground">—</span>}
</TableCell>
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1.5">
<Button
size="sm"
className="h-7 px-2 text-xs"
disabled={deciding === row.id}
onClick={() => decide(row.id, "allowed")}
>
<CheckIcon className="mr-1 h-3 w-3" />
允许
</Button>
<Button
size="sm"
variant="destructive"
className="h-7 px-2 text-xs"
disabled={deciding === row.id}
onClick={() => decide(row.id, "denied")}
>
<XIcon className="mr-1 h-3 w-3" />
拒绝
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</section>
)}
{/* history section */}
<section className="rounded-lg border">
<div className="border-b px-4 py-3 text-sm font-medium text-muted-foreground">
全部记录 ({rows.length})
</div>
{rows.length === 0 && !loading ? (
<div className="py-16 text-center text-sm text-muted-foreground">暂无审批记录</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>工具</TableHead>
<TableHead>来源</TableHead>
<TableHead>匹配规则</TableHead>
<TableHead className="w-48">参数</TableHead>
<TableHead>状态</TableHead>
<TableHead>申请时间</TableHead>
<TableHead>决定时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id} className={row.status === "pending" ? "opacity-40" : ""}>
<TableCell className="text-muted-foreground">{row.id}</TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
</TableCell>
<TableCell><SourceCell row={row} /></TableCell>
<TableCell>
{row.rule_name
? <span className="text-sm">{row.rule_name}</span>
: <span className="text-muted-foreground">—</span>}
</TableCell>
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
<TableCell><StatusBadge status={row.status} /></TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{row.decided_at ? fmtTime(row.decided_at) : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,633 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import {
ListFilterIcon,
PencilIcon,
PlusIcon,
ShieldAlertIcon,
Trash2Icon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent } from "@/components/ui/card";
import { api } from "@/lib/api";
import type { InterceptRule, InterceptAction, Tool } from "@/lib/types";
// ---- tool scope ----
// SDK tools are intentionally not seeded into the DB (they apply to every agent
// and have no per-agent binding). We hardcode them here so they still appear in
// the scope dialog.
function sdkTool(key: string, description: string): Tool {
return { key, system: true, description, schema: {}, agents: [], enabled: true, kind: "builtin" };
}
const SDK_EXEC: Tool[] = [
sdkTool("Bash", "在 shell 中执行命令"),
sdkTool("WebFetch", "发起 HTTP/HTTPS 请求(含代理支持)"),
sdkTool("web_search", "网络搜索"),
sdkTool("shell_open", "开启持久 PTY 交互会话"),
sdkTool("shell_send", "向交互会话发送输入"),
sdkTool("shell_read", "读取交互会话输出"),
sdkTool("shell_close", "关闭交互会话"),
sdkTool("shell_list", "列出所有交互会话"),
];
const SDK_WRITE: Tool[] = [
sdkTool("Write", "写入文件"),
sdkTool("Edit", "编辑文件(精确替换)"),
sdkTool("MultiEdit", "批量编辑文件"),
];
const SDK_KEYS = new Set([...SDK_EXEC, ...SDK_WRITE].map((t) => t.key));
function groupTools(dbTools: Tool[]) {
const sys: Tool[] = [], custom: Tool[] = [];
for (const t of dbTools) {
if (SDK_KEYS.has(t.key)) continue; // already covered by hardcoded groups
if (t.system) sys.push(t);
else custom.push(t);
}
return [
{ label: "执行类", tools: SDK_EXEC },
{ label: "写入/编辑类", tools: SDK_WRITE },
{ label: "系统工具", tools: sys },
{ label: "自定义工具", tools: custom },
].filter((g) => g.tools.length > 0);
}
// ---- form state ----
type RuleForm = {
name: string;
enabled: boolean;
priority: number;
match_target: "tool_name" | "tool_input";
match_type: "string" | "regex";
pattern: string;
action: InterceptAction;
message: string;
timeout_enabled: boolean;
timeout_seconds: number;
timeout_action: "deny" | "allow";
};
const defaultForm = (): RuleForm => ({
name: "",
enabled: true,
priority: 0,
match_target: "tool_name",
match_type: "string",
pattern: "",
action: "deny",
message: "",
timeout_enabled: true,
timeout_seconds: 60,
timeout_action: "deny",
});
// ---- small components ----
function ActionBadge({ action }: { action: InterceptAction }) {
if (action === "allow") return <Badge variant="secondary">允许</Badge>;
if (action === "deny") return <Badge variant="destructive">禁止</Badge>;
return <Badge variant="outline" className="border-amber-400 text-amber-600">申请</Badge>;
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="space-y-1.5">
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{label}
</Label>
{children}
</div>
);
}
// ---- page ----
export default function InterceptPage() {
const [rules, setRules] = React.useState<InterceptRule[]>([]);
const [loading, setLoading] = React.useState(true);
const [open, setOpen] = React.useState(false);
const [editing, setEditing] = React.useState<InterceptRule | null>(null);
const [form, setForm] = React.useState<RuleForm>(defaultForm());
const [saving, setSaving] = React.useState(false);
const [regexErr, setRegexErr] = React.useState("");
const [regexWarn, setRegexWarn] = React.useState(false); // true = JS 无法解析但可能是合法 Go 语法
// ---- tool scope dialog ----
const [scopeOpen, setScopeOpen] = React.useState(false);
const [allTools, setAllTools] = React.useState<Tool[]>([]);
const [enabledTools, setEnabledTools] = React.useState<Set<string>>(new Set());
const [scopeLoading, setScopeLoading] = React.useState(false);
const [scopeSaving, setScopeSaving] = React.useState(false);
// ---- data ----
const load = React.useCallback(async () => {
try {
const r = await api.interceptRules();
setRules(r);
} catch {
toast.error("加载拦截规则失败");
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => { load(); }, [load]);
React.useEffect(() => {
if (form.match_type !== "regex" || !form.pattern) { setRegexErr(""); setRegexWarn(false); return; }
try {
new RegExp(form.pattern);
setRegexErr("");
setRegexWarn(false);
} catch {
// JS RegExp 不支持 Go RE2 扩展语法(如 (?i) 内联 flag)。
// 这里只是预览校验失败,不代表 Go 端无效;交给服务端最终验证。
setRegexErr("");
setRegexWarn(true);
}
}, [form.pattern, form.match_type]);
// ---- rule handlers ----
function set(patch: Partial<RuleForm>) { setForm(f => ({ ...f, ...patch })); }
function openNew() {
setEditing(null);
setForm(defaultForm());
setRegexErr("");
setOpen(true);
}
function openEdit(rule: InterceptRule) {
setEditing(rule);
setForm({
name: rule.name, enabled: rule.enabled, priority: rule.priority,
match_target: rule.match_target, match_type: rule.match_type,
pattern: rule.pattern, action: rule.action, message: rule.message,
timeout_enabled: rule.timeout_enabled, timeout_seconds: rule.timeout_seconds,
timeout_action: rule.timeout_action,
});
setRegexErr("");
setOpen(true);
}
async function handleSave() {
if (!form.name.trim()) { toast.error("名称不能为空"); return; }
if (!form.pattern.trim()) { toast.error("模式不能为空"); return; }
if (regexErr) { toast.error("正则表达式语法无效"); return; }
setSaving(true);
try {
if (editing) {
await api.updateInterceptRule(editing.id, form);
toast.success("规则已更新");
} else {
await api.createInterceptRule(form);
toast.success("规则已创建");
}
setOpen(false);
load();
} catch (e) {
toast.error((e as Error).message);
} finally {
setSaving(false);
}
}
async function handleDelete(id: number) {
try {
await api.deleteInterceptRule(id);
toast.success("规则已删除");
load();
} catch (e) {
toast.error((e as Error).message);
}
}
async function handleToggle(rule: InterceptRule) {
try {
await api.toggleInterceptRule(rule.id, !rule.enabled);
load();
} catch (e) {
toast.error((e as Error).message);
}
}
// ---- scope handlers ----
async function openScope() {
setScopeOpen(true);
setScopeLoading(true);
try {
const [tools, cfg] = await Promise.all([api.tools(), api.interceptGetToolConfig()]);
setAllTools(tools);
setEnabledTools(new Set(cfg.enabled_tools));
} catch (e) {
toast.error("加载失败: " + (e as Error).message);
} finally {
setScopeLoading(false);
}
}
function toggleTool(key: string, val: boolean) {
setEnabledTools(prev => {
const next = new Set(prev);
if (val) next.add(key); else next.delete(key);
return next;
});
}
async function saveScope() {
setScopeSaving(true);
try {
await api.interceptSetToolConfig([...enabledTools]);
toast.success("拦截范围已保存");
setScopeOpen(false);
} catch (e) {
toast.error("保存失败: " + (e as Error).message);
} finally {
setScopeSaving(false);
}
}
const toolGroups = React.useMemo(() => groupTools(allTools), [allTools]);
// ---- render ----
return (
<div className="flex flex-1 flex-col gap-6 p-6">
{/* ---- header ---- */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-2.5">
<ShieldAlertIcon className="h-5 w-5 mt-0.5 shrink-0" />
<div>
<h1 className="text-lg font-semibold leading-tight">拦截规则</h1>
<p className="text-sm text-muted-foreground mt-0.5">
工具执行前按优先级(数字越大越先)逐条匹配,首条命中的规则生效
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button variant="outline" size="sm" onClick={openScope}>
<ListFilterIcon className="h-4 w-4" />
拦截范围
</Button>
<Button onClick={openNew} size="sm">
<PlusIcon className="h-4 w-4" />
新建规则
</Button>
</div>
</div>
{/* ---- rules table ---- */}
<Card>
<CardContent className="p-0">
{loading ? (
<p className="p-6 text-sm text-muted-foreground">加载中…</p>
) : rules.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
<ShieldAlertIcon className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">暂无规则</p>
<Button size="sm" variant="outline" onClick={openNew}>
<PlusIcon className="h-4 w-4" />
新建第一条规则
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[72px]">优先级</TableHead>
<TableHead>名称</TableHead>
<TableHead className="w-[90px]">目标</TableHead>
<TableHead className="w-[80px]">类型</TableHead>
<TableHead>模式</TableHead>
<TableHead className="w-[72px]">策略</TableHead>
<TableHead className="w-[64px] text-center">启用</TableHead>
<TableHead className="w-[80px]" />
</TableRow>
</TableHeader>
<TableBody>
{rules.map((rule) => (
<TableRow key={rule.id} className={!rule.enabled ? "opacity-40" : ""}>
<TableCell>
<span className="font-mono text-xs tabular-nums">{rule.priority}</span>
</TableCell>
<TableCell className="font-medium text-sm">{rule.name}</TableCell>
<TableCell>
<span className="text-xs text-muted-foreground">
{rule.match_target === "tool_name" ? "工具名" : "输入内容"}
</span>
</TableCell>
<TableCell>
<span className="text-xs text-muted-foreground">
{rule.match_type === "regex" ? "正则" : "字符串"}
</span>
</TableCell>
<TableCell className="max-w-[220px]">
<code className="block truncate rounded bg-muted px-1.5 py-0.5 text-xs font-mono">
{rule.pattern}
</code>
</TableCell>
<TableCell>
<ActionBadge action={rule.action} />
</TableCell>
<TableCell className="text-center">
<Switch
checked={rule.enabled}
onCheckedChange={() => handleToggle(rule)}
/>
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-0.5">
<Button
size="icon" variant="ghost" className="h-7 w-7"
onClick={() => openEdit(rule)}
>
<PencilIcon className="h-3.5 w-3.5" />
</Button>
<Button
size="icon" variant="ghost"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(rule.id)}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* ---- editor sheet ---- */}
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="right" className="flex flex-col gap-0 p-0 sm:max-w-md">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{editing ? "编辑规则" : "新建规则"}</SheetTitle>
<SheetDescription className="text-xs">
优先级越大越先匹配;首条命中规则生效,后续跳过
</SheetDescription>
</SheetHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 space-y-5">
<Field label="名称">
<Input
placeholder="给规则起个名字"
value={form.name}
onChange={(e) => set({ name: e.target.value })}
/>
</Field>
<Field label="优先级(数字越大越先匹配)">
<Input
type="number"
value={form.priority}
onChange={(e) => set({ priority: parseInt(e.target.value) || 0 })}
/>
</Field>
<Separator />
<Field label="匹配目标">
<Select
value={form.match_target}
onValueChange={(v) => set({ match_target: v as RuleForm["match_target"] })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="tool_name">工具名(tool_name)</SelectItem>
<SelectItem value="tool_input">输入内容(tool_input JSON)</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="匹配类型">
<Select
value={form.match_type}
onValueChange={(v) => set({ match_type: v as RuleForm["match_type"] })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="string">字符串包含</SelectItem>
<SelectItem value="regex">正则表达式</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="模式">
<Input
placeholder={form.match_type === "regex" ? "^Bash$" : "rm -rf"}
value={form.pattern}
onChange={(e) => set({ pattern: e.target.value })}
className={regexErr ? "border-destructive focus-visible:ring-destructive" : ""}
/>
{regexErr && (
<p className="text-xs text-destructive mt-1">{regexErr}</p>
)}
{regexWarn && (
<p className="text-xs text-amber-600 mt-1">包含 Go RE2 扩展语法(如 <code className="font-mono">(?i)</code>),浏览器无法预览,提交后由服务端验证</p>
)}
</Field>
<Separator />
<Field label="拦截策略">
<Select
value={form.action}
onValueChange={(v) => set({ action: v as InterceptAction })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="allow">允许 — 直接放行,跳过后续规则</SelectItem>
<SelectItem value="deny">禁止 — 阻断,返回拒绝消息给模型</SelectItem>
<SelectItem value="ask">向用户申请 — 等待审批</SelectItem>
</SelectContent>
</Select>
</Field>
{form.action !== "allow" && (
<Field label={form.action === "deny" ? "拒绝消息(返回给模型)" : "审批说明(可选)"}>
<Textarea
placeholder={form.action === "deny" ? "操作被安全策略阻止" : ""}
value={form.message}
onChange={(e) => set({ message: e.target.value })}
rows={2}
className="resize-none"
/>
</Field>
)}
{form.action === "ask" && (
<>
<Separator />
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">启用审批超时</p>
<p className="text-xs text-muted-foreground">超时后自动处置,不再等待</p>
</div>
<Switch
checked={form.timeout_enabled}
onCheckedChange={(v) => set({ timeout_enabled: v })}
/>
</div>
{form.timeout_enabled && (
<div className="flex items-end gap-3">
<Field label="超时时间(秒)">
<Input
type="number"
min={5}
className="w-28"
value={form.timeout_seconds}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
if (n > 0) set({ timeout_seconds: n });
}}
/>
</Field>
<Field label="超时动作">
<Select
value={form.timeout_action}
onValueChange={(v) => set({ timeout_action: v as "deny" | "allow" })}
>
<SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="deny">自动拒绝</SelectItem>
<SelectItem value="allow">自动允许</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
)}
</>
)}
<Separator />
<div className="flex items-center gap-3">
<Switch
id="rule-enabled"
checked={form.enabled}
onCheckedChange={(v) => set({ enabled: v })}
/>
<Label htmlFor="rule-enabled" className="cursor-pointer">启用此规则</Label>
</div>
</div>
<SheetFooter className="border-t px-6 py-4 flex-row justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)}>取消</Button>
<Button onClick={handleSave} disabled={saving || !!regexErr}>
{saving ? "保存中…" : "保存"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
{/* ---- scope dialog ---- */}
<Dialog open={scopeOpen} onOpenChange={setScopeOpen}>
<DialogContent className="sm:max-w-lg flex flex-col overflow-hidden p-0 gap-0" style={{ maxHeight: "min(80vh, 560px)" }}>
<DialogHeader className="shrink-0 border-b px-6 py-4">
<DialogTitle className="flex items-center gap-2">
<ListFilterIcon className="h-4 w-4" />
拦截范围
</DialogTitle>
<DialogDescription className="text-xs">
只有启用拦截的工具才会进入规则匹配;其余工具直接放行
</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-4 space-y-5">
{scopeLoading ? (
<p className="text-sm text-muted-foreground py-4">加载中…</p>
) : (
toolGroups.map((group, gi) => (
<div key={group.label}>
{gi > 0 && <Separator className="mb-5" />}
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
{group.label}
</p>
<div className="space-y-0.5">
{group.tools.map((t) => (
<div key={t.key} className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50">
<Switch
id={`scope-${t.key}`}
checked={enabledTools.has(t.key)}
onCheckedChange={(v) => toggleTool(t.key, v)}
/>
<label htmlFor={`scope-${t.key}`} className="flex-1 min-w-0 cursor-pointer">
<div className="flex items-center gap-1.5">
<span className="font-mono text-sm">{t.key}</span>
{(t.kind && t.kind !== "builtin") && (
<Badge variant="outline" className="px-1 py-0 text-[10px]">{t.kind}</Badge>
)}
</div>
{t.description && (
<p className="text-[11px] text-muted-foreground line-clamp-1">{t.description}</p>
)}
</label>
</div>
))}
</div>
</div>
))
)}
</div>
<div className="shrink-0 border-t px-6 py-3 flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => setScopeOpen(false)}>取消</Button>
<Button size="sm" onClick={saveScope} disabled={scopeSaving || scopeLoading}>
{scopeSaving ? "保存中…" : "保存"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+692
View File
@@ -0,0 +1,692 @@
"use client";
import * as React from "react";
import { Loader2Icon, PlugZapIcon, PlusIcon, SaveIcon, StarIcon, Trash2Icon } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { api } from "@/lib/api";
import type { LLMProfile } from "@/lib/types";
import { cn } from "@/lib/utils";
// 思考强度档位(仅在开关打开时生效)。
const EFFORT_LEVELS: { value: string; label: string }[] = [
{ value: "low", label: "低" },
{ value: "medium", label: "中" },
{ value: "high", label: "高" },
{ value: "max", label: "最大" },
];
// 思考模式三态 ↔ 存库字符串 reasoning_effort:
// none→""(thinking / reasoning_effort 两个字段都【不发送】,兼容不支持该字段的模型,如 MiniMax)
// off →"off"(发送 thinking:{type:disabled},用于默认开启思考、需要显式关闭的模型)
// on →强度值(发送 thinking:{enabled} + reasoning_effort)
type ThinkMode = "none" | "off" | "on";
const THINK_MODES: { value: ThinkMode; label: string }[] = [
{ value: "none", label: "不发送(默认)" },
{ value: "off", label: "显式关闭" },
{ value: "on", label: "开启" },
];
const toStore = (mode: ThinkMode, effort: string) => (mode === "on" ? effort : mode === "off" ? "off" : "");
const modeFromStore = (v?: string): ThinkMode => (!v ? "none" : v === "off" ? "off" : "on");
const effortFromStore = (v?: string) => (v && v !== "off" ? v : "high");
function NewProfileDialog({ onCreated }: { onCreated: (id: string) => void }) {
const [open, setOpen] = React.useState(false);
const [name, setName] = React.useState("");
const [format, setFormat] = React.useState<"anthropic" | "openai">("anthropic");
const [model, setModel] = React.useState("");
const [baseUrl, setBaseUrl] = React.useState("");
const [proxy, setProxy] = React.useState("");
const [apiKey, setApiKey] = React.useState("");
const [rps, setRps] = React.useState("0");
const [rpm, setRpm] = React.useState("0");
const [cw, setCw] = React.useState("0");
const [thinkMode, setThinkMode] = React.useState<ThinkMode>("none");
const [effort, setEffort] = React.useState("high");
function reset() {
setName("");
setFormat("anthropic");
setModel("");
setBaseUrl("");
setProxy("");
setApiKey("");
setRps("0");
setRpm("0");
setCw("0");
setThinkMode("none");
setEffort("high");
}
async function create() {
if (!name.trim() || !model.trim()) {
toast.error("请填写名称与模型");
return;
}
try {
const { id } = await api.saveLLMProfile({
name: name.trim(),
format,
model: model.trim(),
base_url: baseUrl.trim(),
proxy: proxy.trim(),
api_key: apiKey,
rate_per_second: Number(rps) || 0,
rate_per_minute: Number(rpm) || 0,
context_window_k: Number(cw) || 0,
reasoning_effort: toStore(thinkMode, effort),
});
toast.success(`已新建 Profile:${name.trim()}(在列表中「设为激活」以启用)`);
reset();
setOpen(false);
onCreated(String(id));
} catch (e) {
toast.error(`新建失败:${(e as Error).message}`);
}
}
return (
<Dialog
open={open}
onOpenChange={(o) => {
setOpen(o);
if (!o) reset();
}}
>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<PlusIcon /> 新建
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>新建 Profile</DialogTitle>
<DialogDescription>新建后不会自动激活,请在列表中「设为激活」以启用。</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="np-name">名称</Label>
<Input
id="np-name"
placeholder="例如:OpenAI 生产"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label>格式</Label>
<Select value={format} onValueChange={(v) => setFormat(v as "anthropic" | "openai")}>
<SelectTrigger>
<SelectValue placeholder="选择格式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="anthropic">Anthropic</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="np-model">模型</Label>
<Input
id="np-model"
className="font-mono"
placeholder="claude-opus-4-8"
value={model}
onChange={(e) => setModel(e.target.value)}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="np-base-url">Base URL(可选)</Label>
<Input
id="np-base-url"
className="font-mono"
placeholder="https://api.openai.com/v1"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="np-proxy">代理(可选)</Label>
<Input
id="np-proxy"
className="font-mono"
placeholder="http://127.0.0.1:8080 · socks5://127.0.0.1:1080"
value={proxy}
onChange={(e) => setProxy(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="np-api-key">API Key</Label>
<Input
id="np-api-key"
type="password"
placeholder="sk-…"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="np-rps">每秒限速</Label>
<Input id="np-rps" type="number" min={0} value={rps} onChange={(e) => setRps(e.target.value)} />
</div>
<div className="grid gap-2">
<Label htmlFor="np-rpm">每分钟限速</Label>
<Input id="np-rpm" type="number" min={0} value={rpm} onChange={(e) => setRpm(e.target.value)} />
</div>
<div className="grid gap-2">
<Label htmlFor="np-cw">上下文窗口(K)</Label>
<Input
id="np-cw"
type="number"
min={0}
max={1000}
value={cw}
onChange={(e) => setCw(e.target.value)}
placeholder="200"
/>
</div>
</div>
<div className="grid gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between gap-4">
<div className="grid gap-0.5">
<Label className="text-sm">思考模式 · Extended Thinking</Label>
<p className="text-muted-foreground text-xs">
不发送=不带思考字段(兼容 MiniMax 等不支持该字段的模型);显式关闭=发 disabled(默认开思考的模型才需要);开启=先推理再作答。
</p>
</div>
<Select value={thinkMode} onValueChange={(v) => setThinkMode(v as ThinkMode)}>
<SelectTrigger className="w-32 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{THINK_MODES.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{thinkMode === "on" && (
<div className="flex items-center justify-between gap-4 border-t pt-3">
<div className="grid gap-0.5">
<Label className="text-sm">思考强度</Label>
<p className="text-muted-foreground text-xs">推理投入档位,越高越深入。</p>
</div>
<Select value={effort} onValueChange={setEffort}>
<SelectTrigger className="w-28 shrink-0">
<SelectValue placeholder="强度" />
</SelectTrigger>
<SelectContent>
{EFFORT_LEVELS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">取消</Button>
</DialogClose>
<Button onClick={create}>新建</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function LLMPage() {
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const [format, setFormat] = React.useState<"anthropic" | "openai">("anthropic");
const [name, setName] = React.useState("");
const [model, setModel] = React.useState("");
const [baseUrl, setBaseUrl] = React.useState("");
const [proxy, setProxy] = React.useState("");
const [apiKey, setApiKey] = React.useState("");
const [keyHint, setKeyHint] = React.useState("");
const [rps, setRps] = React.useState("0");
const [rpm, setRpm] = React.useState("0");
const [cw, setCw] = React.useState("0"); // 上下文窗口(K tokens);0=默认200K
const [thinkMode, setThinkMode] = React.useState<ThinkMode>("none");
const [effort, setEffort] = React.useState("high");
const [testing, setTesting] = React.useState(false);
const load = React.useCallback(async () => {
try {
const ps = await api.llmProfiles();
setProfiles(ps);
setSelectedId((prev) => {
if (prev && ps.some((p) => p.id === prev)) return prev;
return ps.find((p) => p.is_default)?.id ?? ps[0]?.id ?? null;
});
} catch {
/* ignore */
}
}, []);
React.useEffect(() => {
void load();
}, [load]);
// Fill the editor from the selected profile. Runs only on explicit reloads /
// selection changes (this page does not poll), so it never clobbers edits.
const selected = React.useMemo(() => profiles.find((p) => p.id === selectedId) ?? null, [profiles, selectedId]);
React.useEffect(() => {
if (!selected) return;
setName(selected.name);
setFormat(selected.format === "anthropic" ? "anthropic" : "openai");
setModel(selected.model);
setBaseUrl(selected.base_url ?? "");
setProxy(selected.proxy ?? "");
setRps(String(selected.rate_per_second ?? 0));
setRpm(String(selected.rate_per_minute ?? 0));
setCw(String(selected.context_window_k ?? 0));
setThinkMode(modeFromStore(selected.reasoning_effort));
setEffort(effortFromStore(selected.reasoning_effort));
setApiKey("");
setKeyHint(selected.api_key_hint ?? "");
}, [selected]);
async function testConnection() {
if (testing) return;
setTesting(true);
try {
// test with the SAME thinking params the profile will run with, so an
// unsupported reasoning field fails here instead of only at run time.
// 传当前选中 profile 的 id:输入框留空时,后端用该 profile 已存的 key 测试。
const r = await api.testLLM(
format,
model,
baseUrl,
apiKey,
proxy,
toStore(thinkMode, effort),
selectedId ? Number(selectedId) : undefined,
);
if (r.ok) toast.success(`连接成功 · ${r.latency_ms ?? "?"}ms · ${r.model ?? model}`);
else toast.error(`连接失败:${r.error ?? "未知"}`);
} catch (e) {
toast.error(`测试出错:${(e as Error).message}`);
} finally {
setTesting(false);
}
}
async function save() {
if (!selectedId) {
toast.error("请先在左侧选择或新建一个 Profile");
return;
}
if (!name.trim() || !model.trim()) {
toast.error("请填写名称与模型");
return;
}
try {
await api.saveLLMProfile({
id: Number(selectedId),
name: name.trim(),
format,
model: model.trim(),
base_url: baseUrl.trim(),
proxy: proxy.trim(),
api_key: apiKey,
rate_per_second: Number(rps) || 0,
rate_per_minute: Number(rpm) || 0,
context_window_k: Number(cw) || 0,
reasoning_effort: toStore(thinkMode, effort),
});
toast.success(selected?.is_default ? "已保存,激活配置即时生效,无需重启" : "已保存");
setApiKey("");
await load();
} catch (e) {
toast.error(`保存失败:${(e as Error).message}`);
}
}
async function activate(id: string) {
try {
await api.activateLLMProfile(id);
const p = profiles.find((x) => x.id === id);
toast.success(`已激活 Profile:${p?.name ?? id}`);
await load();
} catch (e) {
toast.error(`激活失败:${(e as Error).message}`);
}
}
async function remove(p: LLMProfile) {
if (p.is_default) {
toast.error("无法删除当前激活的 Profile");
return;
}
try {
await api.deleteLLMProfile(p.id);
toast.success(`已删除 Profile:${p.name}`);
await load();
} catch (e) {
toast.error(`删除失败:${(e as Error).message}`);
}
}
return (
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div>
<h1 className="font-semibold text-xl tracking-tight">LLM</h1>
<p className="text-muted-foreground text-sm">全 Agent 共享的格式 / 模型 / 限速配置</p>
</div>
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div className="grid grid-cols-1 gap-4 md:gap-6 lg:grid-cols-3">
<Card className="lg:order-2 lg:col-span-2">
<CardHeader>
<CardTitle>
{selected ? (
<span className="flex items-center gap-2">
编辑 Profile:{selected.name}
{selected.is_default && (
<Badge variant="outline" className="border-amber-400/50 text-amber-500">
激活中
</Badge>
)}
</span>
) : (
"格式配置"
)}
</CardTitle>
<CardDescription>
{selected
? "修改后点击「保存」。若为激活 Profile,保存后对全部 Agent 立即生效。"
: "从左侧选择一个 Profile 进行编辑,或点击「新建」。"}
</CardDescription>
</CardHeader>
{selected ? (
<CardContent className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="name">名称</Label>
<Input
id="name"
placeholder="Profile 名称"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label>格式</Label>
<Select value={format} onValueChange={(v) => setFormat(v as "anthropic" | "openai")}>
<SelectTrigger>
<SelectValue placeholder="选择格式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="anthropic">Anthropic</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="model">模型</Label>
<Input
id="model"
className="font-mono"
placeholder="claude-opus-4-8"
value={model}
onChange={(e) => setModel(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="base-url">Base URL(可选)</Label>
<Input
id="base-url"
className="font-mono"
placeholder="https://api.openai.com/v1"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy">代理(可选)</Label>
<Input
id="proxy"
className="font-mono"
placeholder="http://127.0.0.1:8080 · socks5://127.0.0.1:1080"
value={proxy}
onChange={(e) => setProxy(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
仅 LLM 出站请求走此代理,支持 http/https/socks5;留空则用环境变量(HTTP_PROXY/HTTPS_PROXY)。
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="api-key">API Key</Label>
<Input
id="api-key"
type="password"
placeholder={keyHint ? `已设置(${keyHint}),留空保持不变` : "sk-…"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="rps">每秒限速</Label>
<Input id="rps" type="number" min={0} value={rps} onChange={(e) => setRps(e.target.value)} />
</div>
<div className="grid gap-2">
<Label htmlFor="rpm">每分钟限速</Label>
<Input id="rpm" type="number" min={0} value={rpm} onChange={(e) => setRpm(e.target.value)} />
<p className="text-muted-foreground text-xs">0 = 不限</p>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="cw">上下文窗口(K tokens)</Label>
<Input
id="cw"
type="number"
min={0}
max={1000}
value={cw}
onChange={(e) => setCw(e.target.value)}
placeholder="200"
/>
<p className="text-muted-foreground text-xs">
模型上下文窗口,用于压缩阈值。单位 K(千 token);0/留空 = 默认 200K;上限 1000(即
1M)。设太高会导致压缩不触发。
</p>
</div>
<div className="grid gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between gap-4">
<div className="grid gap-0.5">
<Label className="text-sm">思考模式 · Extended Thinking</Label>
<p className="text-muted-foreground text-xs">
不发送=不带思考字段(兼容 MiniMax 等不支持该字段的模型);显式关闭=发 disabled(默认开思考的模型才需要);开启=先推理再作答。
</p>
</div>
<Select value={thinkMode} onValueChange={(v) => setThinkMode(v as ThinkMode)}>
<SelectTrigger className="w-32 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{THINK_MODES.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{thinkMode === "on" && (
<div className="flex items-center justify-between gap-4 border-t pt-3">
<div className="grid gap-0.5">
<Label className="text-sm">思考强度</Label>
<p className="text-muted-foreground text-xs">
推理投入档位,越高越深入(OpenAI reasoning_effort / Anthropic output_config.effort)。
</p>
</div>
<Select value={effort} onValueChange={setEffort}>
<SelectTrigger className="w-28 shrink-0">
<SelectValue placeholder="强度" />
</SelectTrigger>
<SelectContent>
{EFFORT_LEVELS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
<Separator />
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={testConnection} disabled={testing}>
{testing ? <Loader2Icon className="animate-spin" /> : <PlugZapIcon />}
{testing ? "测试中…" : "测试连接"}
</Button>
<Button onClick={save}>
<SaveIcon /> 保存
</Button>
</div>
</CardContent>
) : (
<CardContent>
<div className="flex items-center justify-center rounded-lg border border-dashed py-16 text-muted-foreground text-sm">
还没有 Profile,点击左侧「新建」创建第一个。
</div>
</CardContent>
)}
</Card>
<Card className="lg:order-1">
<CardHeader className="has-data-[slot=card-action]:grid-cols-[1fr_auto]">
<CardTitle>Profiles</CardTitle>
<CardDescription>点击选中以编辑;全 Agent 用同一激活 Profile,限速全局共享。</CardDescription>
<CardAction className="self-center">
<NewProfileDialog
onCreated={(id) => {
setSelectedId(id);
void load();
}}
/>
</CardAction>
</CardHeader>
<CardContent className="grid gap-3">
{profiles.map((p) => (
// biome-ignore lint/a11y/useSemanticElements: row wraps its own action buttons, so a native <button> would nest buttons (invalid HTML)
<div
key={p.id}
role="button"
tabIndex={0}
onClick={() => setSelectedId(p.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedId(p.id);
}
}}
aria-pressed={p.id === selectedId}
className={cn(
"cursor-pointer rounded-lg border p-3 text-left outline-none transition-colors",
p.is_default && "border-amber-400/50 bg-amber-400/5",
p.id === selectedId ? "ring-2 ring-primary" : "hover:border-foreground/30",
)}
>
<div className="flex items-start gap-2">
<StarIcon
className={cn(
"mt-0.5 size-4 shrink-0",
p.is_default ? "fill-amber-400 text-amber-400" : "text-muted-foreground",
)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-sm">{p.name}</span>
<Badge variant="outline" className="uppercase">
{p.format}
</Badge>
</div>
<code className="mt-1 block truncate font-mono text-muted-foreground text-xs">{p.model}</code>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-muted-foreground text-xs">
<span>{p.api_key_hint}</span>
<span>
{p.rate_per_second}/s · {p.rate_per_minute}/min
</span>
{p.proxy && <span className="truncate">代理 {p.proxy}</span>}
{p.reasoning_effort && (
<span>思考 {p.reasoning_effort === "off" ? "关" : p.reasoning_effort}</span>
)}
</div>
</div>
</div>
<div className="mt-2 flex gap-2">
<Button
size="sm"
variant="outline"
className="flex-1"
disabled={p.is_default}
onClick={(e) => {
e.stopPropagation();
void activate(p.id);
}}
>
{p.is_default ? "已激活" : "设为激活"}
</Button>
<Button
size="icon"
variant="outline"
aria-label="删除 Profile"
onClick={(e) => {
e.stopPropagation();
void remove(p);
}}
>
<Trash2Icon className="text-destructive" />
</Button>
</div>
</div>
))}
{profiles.length === 0 && (
<div className="rounded-lg border border-dashed p-6 text-center text-muted-foreground text-sm">
暂无 Profile
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}
+230
View File
@@ -0,0 +1,230 @@
"use client";
import * as React from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { sseUrl } from "@/lib/api";
import { MOCK } from "@/lib/mock/enabled";
import type { LogLine } from "@/lib/types";
import { cn } from "@/lib/utils";
// Mock demo:无后端 SSE,塞几行示例日志。
const MOCK_LOGS: LogLine[] = [
{ seq: 1, ts: "2026-07-26T03:55:00Z", level: "info", tag: "engine", text: "ARTEX v0.1.0 backend listening on :8787 (workers=3)" },
{ seq: 2, ts: "2026-07-26T03:55:01Z", level: "info", tag: "config", text: "LLM configured from DB: anthropic / claude-opus-4-8" },
{ seq: 3, ts: "2026-07-26T03:56:10Z", level: "info", tag: "planner", text: "task t-acme-web: 第 3 轮规划,生成意图 i-4" },
{ seq: 4, ts: "2026-07-26T03:57:00Z", level: "warn", tag: "guard", text: "block bash: 目标越界 out.evil.example 不在 scope 内" },
{ seq: 5, ts: "2026-07-26T03:57:30Z", level: "info", tag: "work#1", text: "report_finding: Default Credentials (high) 已落库" },
{ seq: 6, ts: "2026-07-26T03:58:20Z", level: "error", tag: "work#3", text: "intercept: mysqldump 命中破坏性规则,等待人工审批" },
];
const levelTone: Record<LogLine["level"], string> = {
info: "text-foreground/80",
warn: "text-amber-600 dark:text-amber-400",
error: "text-red-600 dark:text-red-400",
};
const levelDot: Record<LogLine["level"], string> = {
info: "bg-muted-foreground/40",
warn: "bg-amber-500",
error: "bg-red-500",
};
function fmtTime(ts: string) {
const d = new Date(ts);
return isNaN(d.getTime()) ? "" : d.toLocaleTimeString("zh-CN", { hour12: false });
}
export default function LogsPage() {
const [lines, setLines] = React.useState<LogLine[]>([]);
const [q, setQ] = React.useState("");
const [level, setLevel] = React.useState<"all" | LogLine["level"]>("all");
const [paused, setPaused] = React.useState(false);
const [loadingHistory, setLoadingHistory] = React.useState(false);
const [hasMore, setHasMore] = React.useState(false);
const bottom = React.useRef<HTMLDivElement>(null);
const stick = React.useRef(true);
const pausedRef = React.useRef(false);
pausedRef.current = paused;
// Minimum db_id seen — used as the cursor for loading older history.
const minDbId = React.useMemo(() => {
let min = 0;
for (const l of lines) {
if (l.db_id && (min === 0 || l.db_id < min)) min = l.db_id;
}
return min;
}, [lines]);
// Live tail via SSE. on connect, replays whatever is in the ring (includes
// DB-restored history from the last 100 rows written before restart).
React.useEffect(() => {
if (MOCK) {
setLines(MOCK_LOGS);
return;
}
const es = new EventSource(sseUrl("/api/logs/stream?since=0"));
es.onmessage = (e) => {
if (pausedRef.current) return;
try {
const l = JSON.parse(e.data) as LogLine;
setLines((prev) => (prev.some((x) => x.seq === l.seq) ? prev : [...prev, l].slice(-3000)));
} catch {
/* ignore malformed frame */
}
};
return () => es.close();
}, []);
// Mark "has more" once we have any db_id in view.
React.useEffect(() => {
if (minDbId > 1) setHasMore(true);
}, [minDbId]);
async function loadOlderHistory() {
if (loadingHistory) return;
setLoadingHistory(true);
try {
const params = minDbId > 0 ? `?before=${minDbId}&limit=200` : `?limit=200`;
const res = await fetch(`/api/logs/history${params}`);
if (!res.ok) return;
const data = await res.json() as { items: LogLine[]; has_more: boolean };
if (data.items?.length) {
// Assign synthetic seq numbers below current minimum to keep dedup working.
setLines((prev) => {
const minSeq = prev.reduce((m, l) => Math.min(m, l.seq ?? 0), 0);
const older = data.items.map((l, i) => ({
...l,
seq: minSeq - data.items.length + i,
}));
// Prepend, dedup by db_id, cap at 5000.
const merged = [...older, ...prev];
const seen = new Set<number>();
const deduped = merged.filter((l) => {
if (!l.db_id) return true;
if (seen.has(l.db_id)) return false;
seen.add(l.db_id);
return true;
});
return deduped.slice(-5000);
});
}
setHasMore(data.has_more ?? false);
} finally {
setLoadingHistory(false);
}
}
const filtered = React.useMemo(() => {
const needle = q.trim().toLowerCase();
return lines.filter(
(l) =>
(level === "all" || l.level === level) &&
(!needle || l.text.toLowerCase().includes(needle) || l.tag.toLowerCase().includes(needle)),
);
}, [lines, q, level]);
React.useEffect(() => {
if (stick.current && !paused) bottom.current?.scrollIntoView();
}, [filtered, paused]);
const counts = React.useMemo(() => {
let warn = 0,
error = 0;
for (const l of lines) {
if (l.level === "warn") warn++;
else if (l.level === "error") error++;
}
return { warn, error, total: lines.length };
}, [lines]);
return (
<div className="flex flex-1 flex-col gap-3">
<div>
<h1 className="text-xl font-semibold tracking-tight">系统日志</h1>
<p className="text-muted-foreground text-sm">后端实时日志流(planner / worker / 数据库 / 流量 …)</p>
</div>
<div className="flex flex-1 flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="过滤(文本 / tag)…"
value={q}
onChange={(e) => setQ(e.target.value)}
className="h-8 max-w-xs"
/>
<div className="flex gap-1">
{(["all", "info", "warn", "error"] as const).map((lv) => (
<Button
key={lv}
size="sm"
variant={level === lv ? "default" : "outline"}
className="h-8"
onClick={() => setLevel(lv)}
>
{lv === "all" ? "全部" : lv}
</Button>
))}
</div>
<Button
size="sm"
variant={paused ? "default" : "outline"}
className="h-8"
onClick={() => setPaused((p) => !p)}
>
{paused ? "已暂停" : "暂停"}
</Button>
<Button size="sm" variant="outline" className="h-8" onClick={() => setLines([])}>
清空
</Button>
<span className="ml-auto text-xs text-muted-foreground">
{counts.total} 行 ·{" "}
<span className="text-amber-600 dark:text-amber-400">{counts.warn} 警告</span> ·{" "}
<span className="text-red-600 dark:text-red-400">{counts.error} 错误</span>
</span>
</div>
<div
onScroll={(e) => {
const el = e.currentTarget;
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}}
className="h-[calc(100vh-14rem)] overflow-auto rounded-lg border bg-card p-2 font-mono text-xs leading-relaxed"
>
{hasMore && (
<div className="flex justify-center py-2">
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
disabled={loadingHistory}
onClick={loadOlderHistory}
>
{loadingHistory ? "加载中…" : "加载更早日志"}
</Button>
</div>
)}
{filtered.length === 0 ? (
<p className="py-10 text-center text-muted-foreground">暂无日志。</p>
) : (
filtered.map((l) => {
const body =
l.tag && l.text.startsWith("[" + l.tag + "]")
? l.text.slice(l.tag.length + 2).trimStart()
: l.text;
return (
<div key={l.seq} className="flex items-start gap-2 px-1 py-0.5 hover:bg-muted/40">
<span className={cn("mt-1.5 size-1.5 shrink-0 rounded-full", levelDot[l.level])} />
<span className="shrink-0 tabular-nums text-muted-foreground">{fmtTime(l.ts)}</span>
{l.tag && (
<span className="shrink-0 rounded bg-muted px-1 text-[10px] text-foreground/70">{l.tag}</span>
)}
<span className={cn("min-w-0 whitespace-pre-wrap break-words", levelTone[l.level])}>{body}</span>
</div>
);
})
)}
<div ref={bottom} />
</div>
</div>
</div>
);
}
+460
View File
@@ -0,0 +1,460 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import { PlusIcon, RefreshCwIcon, ServerIcon, Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from "@/components/ui/sheet";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { api } from "@/lib/api";
import type { MCPServer, MCPTool, Agent } from "@/lib/types";
type Transport = "stdio" | "http";
type FormState = {
name: string;
transport: Transport;
command: string;
args: string;
url: string;
env: string;
};
const emptyForm: FormState = {
name: "",
transport: "stdio",
command: "",
args: "",
url: "",
env: "",
};
export default function MCPPage() {
const [servers, setServers] = React.useState<MCPServer[]>([]);
const [agents, setAgents] = React.useState<Agent[]>([]);
const [visibility, setVisibility] = React.useState<Record<number, string[]>>({});
const [open, setOpen] = React.useState(false);
const [editing, setEditing] = React.useState<MCPServer | null>(null); // null = add mode
const [tab, setTab] = React.useState<"config" | "tools">("config");
const [form, setForm] = React.useState<FormState>(emptyForm);
const [saving, setSaving] = React.useState(false);
const [tools, setTools] = React.useState<MCPTool[]>([]);
const [toolsLoading, setToolsLoading] = React.useState(false);
const [refreshing, setRefreshing] = React.useState(false);
const load = React.useCallback(() => {
api.agents().then(setAgents).catch(() => {});
api
.mcpServers()
.then((ss) => {
setServers(ss);
ss.forEach((s) =>
api
.resourceVisibility("mcp", s.id)
.then((ids) => setVisibility((v) => ({ ...v, [s.id]: ids })))
.catch(() => {}),
);
})
.catch(() => {});
}, []);
React.useEffect(() => {
load();
}, [load]);
function setF(patch: Partial<FormState>) {
setForm((f) => ({ ...f, ...patch }));
}
function parseEnv(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of text.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const idx = trimmed.indexOf("=");
if (idx > 0) out[trimmed.slice(0, idx)] = trimmed.slice(idx + 1);
}
return out;
}
function envToText(env: Record<string, string> | undefined): string {
return Object.entries(env ?? {})
.map(([k, v]) => `${k}=${v}`)
.join("\n");
}
function openAdd() {
setEditing(null);
setForm(emptyForm);
setTools([]);
setTab("config");
setOpen(true);
}
function openEdit(s: MCPServer) {
setEditing(s);
setForm({
name: s.name,
transport: s.transport,
command: s.command ?? "",
args: (s.args ?? []).join(" "),
url: s.url ?? "",
env: envToText(s.env),
});
setTab("config");
setOpen(true);
loadTools(s.id);
}
async function loadTools(id: number) {
setToolsLoading(true);
try {
setTools(await api.mcpTools(id));
} catch {
setTools([]);
} finally {
setToolsLoading(false);
}
}
async function saveForm() {
if (!form.name.trim()) {
toast.error("请填写名称");
return;
}
if (form.transport === "stdio" && !form.command.trim()) {
toast.error("请填写命令");
return;
}
if (form.transport === "http" && !form.url.trim()) {
toast.error("请填写远程 URL");
return;
}
setSaving(true);
try {
const base =
form.transport === "http"
? {
transport: "http" as const,
url: form.url.trim(),
command: "",
args: [] as string[],
env: parseEnv(form.env), // 远程模式下 env 即请求头
}
: {
transport: "stdio" as const,
command: form.command.trim(),
args: form.args.trim() ? form.args.trim().split(/\s+/) : [],
env: parseEnv(form.env),
};
await api.saveMcpServer({
...(editing ? { id: editing.id } : {}),
name: form.name.trim(),
enabled: editing ? editing.enabled : true,
...base,
});
toast.success(editing ? "已保存" : "已添加 MCP 服务器");
if (!editing) setOpen(false);
load();
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
} finally {
setSaving(false);
}
}
async function refreshTools() {
if (!editing) return;
setRefreshing(true);
try {
const t = await api.refreshMcpServer(editing.id);
setTools(t);
toast.success(`发现 ${t.length} 个工具`);
load();
} catch (e) {
toast.error("刷新失败:" + (e as Error).message);
} finally {
setRefreshing(false);
}
}
async function removeServer(s: MCPServer) {
try {
await api.deleteMcpServer(s.id);
toast.success(`已删除:${s.name}`);
setOpen(false);
load();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}
async function toggleEnabled(s: MCPServer) {
try {
await api.saveMcpServer({ ...s, enabled: !s.enabled });
load();
} catch (e) {
toast.error("操作失败:" + (e as Error).message);
}
}
async function toggleVisibility(serverId: number, agentId: string, agentName: string) {
const on = (visibility[serverId] ?? []).includes(agentId);
try {
await api.toggleVisibility(agentId, "mcp", serverId, !on);
toast.success(`${on ? "取消" : "授予"}「${agentName}」可见`);
load();
} catch (e) {
toast.error("操作失败:" + (e as Error).message);
}
}
function renderForm() {
return (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>传输方式</Label>
<div className="flex gap-2">
<Button
type="button"
variant={form.transport === "stdio" ? "default" : "outline"}
onClick={() => setF({ transport: "stdio" })}
>
stdio(本地)
</Button>
<Button
type="button"
variant={form.transport === "http" ? "default" : "outline"}
onClick={() => setF({ transport: "http" })}
>
http(远程)
</Button>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="m-name">名称</Label>
<Input
id="m-name"
placeholder="filesystem"
value={form.name}
onChange={(e) => setF({ name: e.target.value })}
/>
</div>
{form.transport === "stdio" ? (
<>
<div className="grid gap-2">
<Label htmlFor="m-cmd">命令</Label>
<Input
id="m-cmd"
className="font-mono"
placeholder="npx"
value={form.command}
onChange={(e) => setF({ command: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="m-args">参数(空格分隔)</Label>
<Input
id="m-args"
className="font-mono"
placeholder="-y @modelcontextprotocol/server-filesystem /data"
value={form.args}
onChange={(e) => setF({ args: e.target.value })}
/>
</div>
</>
) : (
<div className="grid gap-2">
<Label htmlFor="m-url">远程 URL</Label>
<Input
id="m-url"
className="font-mono"
placeholder="https://mcp.example.com/mcp"
value={form.url}
onChange={(e) => setF({ url: e.target.value })}
/>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="m-env">
{form.transport === "http"
? "请求头(每行 KEY=VALUE,如 Authorization=Bearer xxx)"
: "环境变量(每行 KEY=VALUE)"}
</Label>
<Textarea
id="m-env"
className="font-mono"
placeholder={
form.transport === "http" ? "Authorization=Bearer xxxx" : "API_KEY=xxxx\nFOO=bar"
}
value={form.env}
onChange={(e) => setF({ env: e.target.value })}
/>
</div>
</div>
);
}
function renderTools() {
return (
<div className="flex flex-col gap-3 py-4">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">{tools.length} 个工具</span>
<Button size="sm" variant="outline" disabled={refreshing} onClick={refreshTools}>
<RefreshCwIcon className={refreshing ? "animate-spin" : ""} /> 刷新
</Button>
</div>
{toolsLoading ? (
<p className="text-muted-foreground text-sm">加载中…</p>
) : tools.length === 0 ? (
<p className="text-muted-foreground text-sm">尚未发现工具,点击刷新重新获取。</p>
) : (
<div className="flex flex-col divide-y">
{tools.map((t) => (
<div key={t.name} className="py-2.5">
<code className="font-mono text-sm">{t.name}</code>
{t.description && (
<p className="text-muted-foreground mt-0.5 text-xs leading-relaxed">
{t.description}
</p>
)}
</div>
))}
</div>
)}
</div>
);
}
return (
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div>
<h1 className="text-xl font-semibold tracking-tight">MCP</h1>
<p className="text-muted-foreground text-sm">外部 MCP 工具服务器 · 按 Agent 授权可见</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<button
type="button"
onClick={openAdd}
className="text-foreground/70 border-foreground/70 hover:bg-muted/60 hover:shadow-sm flex min-h-[116px] flex-col items-center justify-center gap-2 rounded-xl border border-dashed transition"
>
<PlusIcon className="size-6" />
<span className="text-sm">添加 MCP</span>
</button>
{servers.map((s) => (
<Card
key={s.id}
onClick={() => openEdit(s)}
className="hover:border-primary/60 cursor-pointer gap-3 transition hover:shadow-sm"
>
<CardHeader>
<div className="flex items-center gap-2">
<ServerIcon className="text-muted-foreground size-4 shrink-0" />
<CardTitle className="truncate text-base">{s.name}</CardTitle>
<Badge variant="outline" className="uppercase">
{s.transport}
</Badge>
<div className="ml-auto flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<Switch
checked={s.enabled}
onCheckedChange={() => toggleEnabled(s)}
aria-label="启用"
/>
<Button
size="icon"
variant="outline"
aria-label="删除"
onClick={() => removeServer(s)}
>
<Trash2Icon className="text-destructive" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="grid gap-3">
<p className="text-muted-foreground text-sm">
{s.tools && s.tools.length > 0 ? `${s.tools.length} 个工具` : "尚未发现工具"}
</p>
<div className="grid gap-2" onClick={(e) => e.stopPropagation()}>
<span className="text-muted-foreground text-xs">可见性(按 Agent 授权)</span>
<div className="flex flex-wrap gap-x-4 gap-y-2">
{agents.map((a) => (
<label key={a.key} className="flex items-center gap-2 text-sm">
<Checkbox
checked={(visibility[s.id] ?? []).includes(a.id)}
onCheckedChange={() => toggleVisibility(s.id, a.id, a.name)}
/>
{a.name}
</label>
))}
</div>
</div>
</CardContent>
</Card>
))}
</div>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="right"
className="w-full data-[side=right]:sm:max-w-lg"
>
<SheetHeader>
<SheetTitle>{editing ? editing.name : "添加 MCP 服务器"}</SheetTitle>
<SheetDescription>
stdio(本地起进程)或 http(远程 Streamable HTTP)
</SheetDescription>
</SheetHeader>
{editing ? (
<Tabs
value={tab}
onValueChange={(v) => setTab(v as "config" | "tools")}
className="flex min-h-0 flex-1 flex-col px-4"
>
<TabsList>
<TabsTrigger value="config">配置</TabsTrigger>
<TabsTrigger value="tools">
工具列表{tools.length ? `(${tools.length})` : ""}
</TabsTrigger>
</TabsList>
<TabsContent value="config" className="min-h-0 flex-1 overflow-y-auto">
{renderForm()}
<div className="flex gap-2 pt-2 pb-6">
<Button onClick={saveForm} disabled={saving}>
保存
</Button>
</div>
</TabsContent>
<TabsContent value="tools" className="min-h-0 flex-1 overflow-y-auto">
{renderTools()}
</TabsContent>
</Tabs>
) : (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
{renderForm()}
<div className="pt-2 pb-6">
<Button onClick={saveForm} disabled={saving}>
<PlusIcon /> 添加
</Button>
</div>
</div>
)}
</SheetContent>
</Sheet>
</div>
);
}
+429
View File
@@ -0,0 +1,429 @@
"use client";
import * as React from "react";
import { CpuIcon, RadioTowerIcon, SearchIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/lib/api";
import type { Settings } from "@/lib/types";
export default function SystemSettingsPage() {
const [trafficCapture, setTrafficCapture] = React.useState(false);
const [webSearch, setWebSearch] = React.useState(false);
const [backend, setBackend] = React.useState("ddgs");
const [braveKeySet, setBraveKeySet] = React.useState(false);
const [braveKeyInput, setBraveKeyInput] = React.useState("");
const [tavilyKeySet, setTavilyKeySet] = React.useState(false);
const [tavilyKeyInput, setTavilyKeyInput] = React.useState("");
const [savingTavilyKey, setSavingTavilyKey] = React.useState(false);
const [proxyInput, setProxyInput] = React.useState("");
const [savingProxy, setSavingProxy] = React.useState(false);
const [testing, setTesting] = React.useState(false);
const [loaded, setLoaded] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [savingKey, setSavingKey] = React.useState(false);
const [pyInterp, setPyInterp] = React.useState("");
const [workers, setWorkers] = React.useState("3");
const [savingWorkers, setSavingWorkers] = React.useState(false);
const apply = React.useCallback((s: Settings) => {
setTrafficCapture(!!s.traffic_capture);
setWebSearch(!!s.web_search_enabled);
setBackend(s.web_search_backend || "ddgs");
setBraveKeySet(!!s.brave_key_set);
setTavilyKeySet(!!s.tavily_key_set);
setProxyInput(s.web_search_proxy || "");
setPyInterp(s.python_interpreter || "");
setWorkers(String(s.workers ?? 3));
}, []);
const saveWorkers = () => {
const n = Number(workers);
if (!Number.isInteger(n) || n <= 0) {
toast.error("并发数必须是大于 0 的整数");
return;
}
setSavingWorkers(true);
api
.setSettings({ workers: n })
.then((s) => {
apply(s);
toast.success("已保存并发工作 agent 数(对之后启动的任务生效)");
})
.catch((e) => toast.error("保存失败:" + (e as Error).message))
.finally(() => setSavingWorkers(false));
};
const savePython = () => {
setSaving(true);
api
.setSettings({ python_interpreter: pyInterp.trim() })
.then((s) => {
apply(s);
toast.success("已保存 Python 解释器配置");
})
.catch((e) => toast.error("保存失败:" + (e as Error).message))
.finally(() => setSaving(false));
};
const detectPython = () => {
setSaving(true);
api.detectPython().then((r) => setPyInterp(r.python_interpreter)).catch(() => undefined).finally(() => setSaving(false));
};
React.useEffect(() => {
api
.settings()
.then(apply)
.catch(() => undefined)
.finally(() => setLoaded(true));
}, [apply]);
const toggleTraffic = (v: boolean) => {
setTrafficCapture(v); // optimistic
setSaving(true);
api
.setSettings({ traffic_capture: v })
.then(apply)
.catch(() => setTrafficCapture(!v)) // revert on failure
.finally(() => setSaving(false));
};
// Persist a web-search patch (enable and/or backend). Optimistic with refetch.
const saveWebSearch = (patch: Partial<Settings>) => {
setSaving(true);
api
.setSettings(patch)
.then((s) => {
apply(s);
toast.success("已保存网络搜索配置");
})
.catch((e) => {
toast.error("保存失败:" + (e as Error).message);
api
.settings()
.then(apply)
.catch(() => undefined);
})
.finally(() => setSaving(false));
};
const saveBraveKey = () => {
setSavingKey(true);
api
.setSettings({ brave_search_api_key: braveKeyInput })
.then((s) => {
apply(s);
setBraveKeyInput("");
toast.success("已保存 Brave API Key");
})
.catch((e) => toast.error("保存失败:" + (e as Error).message))
.finally(() => setSavingKey(false));
};
const saveTavilyKey = () => {
setSavingTavilyKey(true);
api
.setSettings({ tavily_search_api_key: tavilyKeyInput })
.then((s) => {
apply(s);
setTavilyKeyInput("");
toast.success("已保存 Tavily API Key");
})
.catch((e) => toast.error("保存失败:" + (e as Error).message))
.finally(() => setSavingTavilyKey(false));
};
const saveProxy = () => {
setSavingProxy(true);
api
.setSettings({ web_search_proxy: proxyInput.trim() })
.then((s) => {
apply(s);
toast.success(proxyInput.trim() ? "已保存出口代理" : "已清除出口代理(改为直连)");
})
.catch((e) => toast.error("保存失败:" + (e as Error).message))
.finally(() => setSavingProxy(false));
};
// Run a real "test" search ("test") against the CURRENT form values (backend +
// proxy + entered key), falling back to saved values server-side. Toasts result.
const runTest = () => {
setTesting(true);
api
.testWebSearch({
web_search_backend: backend,
web_search_proxy: proxyInput.trim(),
brave_search_api_key: braveKeyInput,
tavily_search_api_key: tavilyKeyInput,
})
.then((r) => {
if (r.ok) toast.success(`搜索测试成功 · ${r.backend} 返回 ${r.count} 条结果`);
else toast.error("搜索测试失败:" + (r.error || "未知错误"));
})
.catch((e) => toast.error("搜索测试失败:" + (e as Error).message))
.finally(() => setTesting(false));
};
// brave-free selected but no key stored and none being entered → tool stays off.
const braveNeedsKey = webSearch && backend === "brave-free" && !braveKeySet;
return (
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div>
<h1 className="text-xl font-semibold tracking-tight">系统配置</h1>
<p className="text-muted-foreground text-sm">全局运行时开关</p>
</div>
<Card className="max-w-2xl">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<RadioTowerIcon className="size-4" />
流量捕获
</CardTitle>
<CardDescription>
开启后,所有 Agent 的 HTTP 流量经记录代理全量落库,并向 Agent 注入 traffic_search / traffic_get
工具与代理配置(提示词含代理说明)。
<br />
关闭(默认)时不记录任何流量:Agent
<b>不会</b>拿到代理配置与流量工具,提示词也<b>不含</b>代理相关内容。切换后会即时重建 Agent 生效。
</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between gap-4">
<Label htmlFor="traffic-capture" className="text-sm font-normal text-muted-foreground">
{trafficCapture ? "已开启 · 正在记录流量并注入代理" : "已关闭 · 不记录、不注入代理"}
</Label>
<Switch
id="traffic-capture"
checked={trafficCapture}
disabled={!loaded || saving}
onCheckedChange={toggleTraffic}
/>
</CardContent>
</Card>
<Card className="max-w-2xl">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<SearchIcon className="size-4" />
网络搜索
</CardTitle>
<CardDescription>
这是网络搜索的<b>总开关 + 来源配置</b>。开启后,才能在<b>每个 Agent 的配置</b>里单独选择是否启用
<b>web_search</b>(仅返回标题/链接/摘要,不抓取正文;抓取由 WebFetch 负责)。网络搜索<b>不走</b>记录代理,独立于流量捕获。
<br />
来源可选 <b>DuckDuckGo(ddgs)</b>(无需 Key)、<b>Brave(免费版)</b>(需填写 Brave API Key)或 <b>Tavily</b>(需填写 Tavily API Key)。总开关关闭时,各 Agent 的网络搜索开关不可用。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<Label htmlFor="web-search" className="text-sm font-normal text-muted-foreground">
{webSearch ? "总开关已开启 · 可在各 Agent 配置里单独启用" : "已关闭 · 各 Agent 无法启用网络搜索"}
</Label>
<Switch
id="web-search"
checked={webSearch}
disabled={!loaded || saving}
onCheckedChange={(v) => {
setWebSearch(v); // optimistic
saveWebSearch({ web_search_enabled: v });
}}
/>
</div>
{webSearch && (
<div className="flex items-center justify-between gap-4">
<Label className="text-sm font-normal text-muted-foreground">搜索来源</Label>
<Select
value={backend}
disabled={!loaded || saving}
onValueChange={(v) => {
setBackend(v); // optimistic
saveWebSearch({ web_search_backend: v });
}}
>
<SelectTrigger className="w-48 shrink-0">
<SelectValue placeholder="选择来源" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ddgs">DuckDuckGo(ddgs · 免费无 Key)</SelectItem>
<SelectItem value="brave-free">Brave(免费版 · 需 Key)</SelectItem>
<SelectItem value="tavily">Tavily(需 Key)</SelectItem>
</SelectContent>
</Select>
</div>
)}
{webSearch && backend === "brave-free" && (
<div className="flex flex-col gap-2">
<Label htmlFor="brave-key" className="text-sm font-normal text-muted-foreground">
Brave Search API Key
{braveKeySet && <span className="ml-2 text-xs text-emerald-500">已配置</span>}
</Label>
<div className="flex items-center gap-2">
<Input
id="brave-key"
type="password"
autoComplete="off"
placeholder={braveKeySet ? "已配置(留空则不变)" : "输入 Brave API Key"}
value={braveKeyInput}
disabled={!loaded || savingKey}
onChange={(e) => setBraveKeyInput(e.target.value)}
/>
<Button
type="button"
onClick={saveBraveKey}
disabled={!loaded || savingKey || braveKeyInput.trim() === ""}
>
保存
</Button>
</div>
{braveNeedsKey && (
<p className="text-xs text-amber-500">
已选择 Brave 但尚未配置 Key —— 在保存 Key 之前,搜索工具不会启用。
</p>
)}
<p className="text-muted-foreground text-xs">
免费版额度约 2,000 次/月。前往 https://brave.com/search/api/ 获取 Key。
</p>
</div>
)}
{webSearch && backend === "tavily" && (
<div className="flex flex-col gap-2">
<Label htmlFor="tavily-key" className="text-sm font-normal text-muted-foreground">
Tavily Search API Key
{tavilyKeySet && <span className="ml-2 text-xs text-emerald-500">已配置</span>}
</Label>
<div className="flex items-center gap-2">
<Input
id="tavily-key"
type="password"
autoComplete="off"
placeholder={tavilyKeySet ? "已配置(留空则不变)" : "输入 Tavily API Key(tvly-…)"}
value={tavilyKeyInput}
disabled={!loaded || savingTavilyKey}
onChange={(e) => setTavilyKeyInput(e.target.value)}
/>
<Button
type="button"
onClick={saveTavilyKey}
disabled={!loaded || savingTavilyKey || tavilyKeyInput.trim() === ""}
>
保存
</Button>
</div>
{webSearch && backend === "tavily" && !tavilyKeySet && (
<p className="text-xs text-amber-500">
已选择 Tavily 但尚未配置 Key —— 在保存 Key 之前,搜索工具不会启用。
</p>
)}
<p className="text-muted-foreground text-xs">
前往 https://tavily.com 注册并获取 API Key。
</p>
</div>
)}
{webSearch && (
<div className="flex flex-col gap-2">
<Label htmlFor="ws-proxy" className="text-sm font-normal text-muted-foreground">
出口代理(可选)
</Label>
<div className="flex items-center gap-2">
<Input
id="ws-proxy"
autoComplete="off"
placeholder="http://host:port 或 socks5://host:port(留空=直连)"
value={proxyInput}
disabled={!loaded || savingProxy}
onChange={(e) => setProxyInput(e.target.value)}
/>
<Button type="button" onClick={saveProxy} disabled={!loaded || savingProxy}>
保存
</Button>
</div>
<p className="text-muted-foreground text-xs">
独立出口代理,仅用于访问搜索端点(VPN/SOCKS 等)。与记录流量的 MITM 代理无关;网络不通时经此代理访问。
</p>
</div>
)}
{webSearch && (
<div className="flex items-center justify-between gap-4 border-t pt-4">
<p className="text-muted-foreground text-xs">
用当前配置(来源 + 代理 + Key)实际搜索一次「test」,验证是否可用。
</p>
<Button type="button" variant="outline" onClick={runTest} disabled={!loaded || testing} className="shrink-0">
{testing ? "测试中…" : "测试搜索"}
</Button>
</div>
)}
</CardContent>
</Card>
<Card className="max-w-2xl">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<RadioTowerIcon className="size-4" />
自定义脚本 · Python 解释器
</CardTitle>
<CardDescription>
自定义 <b>script</b> 类型工具用它跑 Python。开机会自动检测(python3 优先);此处可手填 venv /
特定版本的绝对路径,留空则运行时自动检测。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Input
className="font-mono text-sm"
placeholder="/usr/bin/python3(留空=自动检测)"
value={pyInterp}
disabled={!loaded || saving}
onChange={(e) => setPyInterp(e.target.value)}
/>
<Button variant="outline" onClick={detectPython} disabled={!loaded || saving}>
重新检测
</Button>
<Button onClick={savePython} disabled={!loaded || saving}>
保存
</Button>
</div>
</CardContent>
</Card>
<Card className="max-w-2xl">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CpuIcon className="size-4" />
工作并发 · Work Agent 数
</CardTitle>
<CardDescription>
每个任务并发运行的工作 agent 数量(默认 3)。数值越大并发探测越多、消耗也越高。修改后<b>对之后启动的任务生效</b>,正在运行的任务不受影响。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
className="w-32 font-mono text-sm"
placeholder="3"
value={workers}
disabled={!loaded || savingWorkers}
onChange={(e) => setWorkers(e.target.value)}
/>
<Button onClick={saveWorkers} disabled={!loaded || savingWorkers}>
保存
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+764
View File
@@ -0,0 +1,764 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import {
ChevronRightIcon,
FolderIcon,
FolderOpenIcon,
FolderPlusIcon,
FileTextIcon,
FilePlusIcon,
PlusIcon,
Trash2Icon,
UploadIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
import type { Agent, SkillItem, MCPServer } from "@/lib/types";
// ── Tree node ──────────────────────────────────────────────────────────────
interface TreeNode {
name: string;
path: string; // relative to skill root, dirs WITHOUT trailing slash
type: "file" | "dir";
children: TreeNode[];
}
// Backend returns dirs as "scripts/" (trailing slash) and files as "scripts/a.py".
function buildTree(entries: string[]): TreeNode[] {
const root: TreeNode[] = [];
const dirMap = new Map<string, TreeNode>();
function ensureDir(dirPath: string, parentNodes: TreeNode[]): TreeNode[] {
if (dirMap.has(dirPath)) return dirMap.get(dirPath)!.children;
const parts = dirPath.split("/");
let nodes = root;
let cur = "";
for (const part of parts) {
cur = cur ? `${cur}/${part}` : part;
if (!dirMap.has(cur)) {
const d: TreeNode = { name: part, path: cur, type: "dir", children: [] };
nodes.push(d);
dirMap.set(cur, d);
}
nodes = dirMap.get(cur)!.children;
}
return nodes;
}
for (const entry of [...entries].sort()) {
if (entry.endsWith("/")) {
// Explicit directory entry — ensure the node exists (may already be created)
ensureDir(entry.slice(0, -1), root);
} else {
// File — ensure parent dirs exist, then push file node
const parts = entry.split("/");
let nodes = root;
let cur = "";
for (let i = 0; i < parts.length - 1; i++) {
cur = cur ? `${cur}/${parts[i]}` : parts[i];
if (!dirMap.has(cur)) {
const d: TreeNode = { name: parts[i], path: cur, type: "dir", children: [] };
nodes.push(d);
dirMap.set(cur, d);
}
nodes = dirMap.get(cur)!.children;
}
const fname = parts[parts.length - 1];
if (fname) nodes.push({ name: fname, path: entry, type: "file", children: [] });
}
}
return root;
}
// ── State types ───────────────────────────────────────────────────────────
type Selected =
| { skill: string; path: null }
| { skill: string; path: string };
type Creating = {
skill: string;
inDir: string;
kind: "file" | "dir";
} | null;
// ── Page ──────────────────────────────────────────────────────────────────
export default function SkillsPage() {
const [skills, setSkills] = React.useState<SkillItem[]>([]);
const [agents, setAgents] = React.useState<Agent[]>([]);
const [selected, setSelected] = React.useState<Selected | null>(null);
const [visibility, setVisibility] = React.useState<Record<string, string[]>>({});
const [expanded, setExpanded] = React.useState<Set<string>>(new Set());
// file editor
const [fileContent, setFileContent] = React.useState("");
const [fileLoading, setFileLoading] = React.useState(false);
const [dirty, setDirty] = React.useState(false);
const [saving, setSaving] = React.useState(false);
// inline create — using a ref to avoid stale closure in onBlur
const [creating, setCreating] = React.useState<Creating>(null);
const [newEntryName, setNewEntryName] = React.useState("");
const inlineRef = React.useRef<HTMLInputElement>(null);
// cancelRef prevents onBlur from committing when Escape was pressed
const cancelRef = React.useRef(false);
// new skill dialog
const [newOpen, setNewOpen] = React.useState(false);
const [newName, setNewName] = React.useState("");
const [newDesc, setNewDesc] = React.useState("");
const [newLicense, setNewLicense] = React.useState("");
const [newCompat, setNewCompat] = React.useState("");
const [newInst, setNewInst] = React.useState("");
const [newMcps, setNewMcps] = React.useState<string[]>([]);
const [newVisibility, setNewVisibility] = React.useState<string[]>([]);
const [mcpOptions, setMcpOptions] = React.useState<MCPServer[]>([]);
const [creatingSkill, setCreatingSkill] = React.useState(false);
const [uploading, setUploading] = React.useState(false);
const uploadRef = React.useRef<HTMLInputElement>(null);
// skill detail — MCP edit state (optimistic, rolls back on error)
const [detailMcps, setDetailMcps] = React.useState<string[]>([]);
// ── Data ──────────────────────────────────────────────────────────────────
const load = React.useCallback(() => {
api.agents().then(setAgents).catch(() => {});
api.mcpServers().then(setMcpOptions).catch(() => {});
api.skills().then((ss) => {
setSkills(ss);
ss.forEach((s) =>
api.skillVisibility(s.name)
.then((ids) => setVisibility((v) => ({ ...v, [s.name]: ids })))
.catch(() => {}),
);
}).catch(() => {});
}, []);
React.useEffect(() => { load(); }, [load]);
// ── Upload a .zip skill ───────────────────────────────────────────────────
async function uploadZip(file: File, overwrite = false) {
setUploading(true);
try {
const r = await api.uploadSkill(file, overwrite);
toast.success(`已安装 Skill:${r.name}(${r.files} 个文件)`);
load();
} catch (e) {
const msg = (e as Error).message;
// offer overwrite when the skill already exists
if (!overwrite && msg.includes("已存在")) {
if (window.confirm(`${msg}\n\n是否覆盖同名 Skill?`)) {
await uploadZip(file, true);
return;
}
} else {
toast.error("上传失败:" + msg);
}
} finally {
setUploading(false);
}
}
function onUploadPick(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0];
e.target.value = ""; // reset so picking the same file again re-fires
if (f) uploadZip(f);
}
React.useEffect(() => {
if (creating) {
// Small timeout so the element is actually in the DOM before focusing
setTimeout(() => inlineRef.current?.focus(), 20);
}
}, [creating]);
// Sync detail-panel MCP state when the selected skill changes.
React.useEffect(() => {
if (!selected || selected.path !== null) return;
const sk = skills.find((s) => s.name === selected.skill);
setDetailMcps(sk?.mcps ?? []);
}, [selected, skills]);
React.useEffect(() => {
if (!selected || selected.path === null) { setFileContent(""); setDirty(false); return; }
setFileLoading(true);
api.readSkillFile(selected.skill, selected.path)
.then((c) => { setFileContent(c); setDirty(false); })
.catch(() => toast.error("读取文件失败"))
.finally(() => setFileLoading(false));
}, [selected]);
// ── Expand helpers ────────────────────────────────────────────────────────
function toggleExpanded(key: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
}
function ensureExpanded(skill: string, dirPath: string) {
setExpanded((prev) => {
const next = new Set(prev);
next.add(skill);
if (dirPath) {
let cur = "";
for (const part of dirPath.split("/")) {
cur = cur ? `${cur}/${part}` : part;
next.add(`${skill}:${cur}`);
}
}
return next;
});
}
// ── Inline create ─────────────────────────────────────────────────────────
function startCreate(skill: string, inDir: string, kind: "file" | "dir") {
cancelRef.current = false;
ensureExpanded(skill, inDir);
setCreating({ skill, inDir, kind });
setNewEntryName("");
}
// Use a ref snapshot so commitCreate reads the latest creating value
// without depending on potentially stale closure state.
const creatingRef = React.useRef<Creating>(null);
React.useEffect(() => { creatingRef.current = creating; }, [creating]);
const newEntryRef = React.useRef("");
React.useEffect(() => { newEntryRef.current = newEntryName; }, [newEntryName]);
async function commitCreate() {
if (cancelRef.current) { cancelRef.current = false; return; }
const c = creatingRef.current;
const name = newEntryRef.current.trim();
setCreating(null);
setNewEntryName("");
if (!c || !name) return;
const fullPath = c.inDir ? `${c.inDir}/${name}` : name;
try {
if (c.kind === "dir") {
await api.createSkillDir(c.skill, fullPath);
toast.success(`已创建文件夹:${fullPath}`);
ensureExpanded(c.skill, fullPath);
} else {
await api.writeSkillFile(c.skill, fullPath, "");
toast.success(`已创建文件:${fullPath}`);
setSelected({ skill: c.skill, path: fullPath });
ensureExpanded(c.skill, c.inDir);
}
load();
} catch (e) {
toast.error("创建失败:" + (e as Error).message);
}
}
function cancelCreate() {
cancelRef.current = true;
setCreating(null);
setNewEntryName("");
}
async function deletePath(skill: string, path: string) {
try {
await api.deleteSkillPath(skill, path);
toast.success(`已删除:${path}`);
if (selected?.skill === skill && selected.path === path) setSelected(null);
load();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}
async function deleteSkill(name: string) {
try {
await api.deleteSkill(name);
toast.success(`已删除 Skill:${name}`);
if (selected?.skill === name) setSelected(null);
load();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}
async function saveFile() {
if (!selected || selected.path === null) return;
setSaving(true);
try {
await api.writeSkillFile(selected.skill, selected.path, fileContent);
toast.success("已保存");
setDirty(false);
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
} finally { setSaving(false); }
}
async function toggleSkillMcp(skillName: string, mcpName: string, mcpOn: boolean) {
const next = mcpOn
? [...detailMcps, mcpName]
: detailMcps.filter((n) => n !== mcpName);
setDetailMcps(next);
try {
await api.updateSkillMeta(skillName, { mcps: next });
toast.success(`${mcpOn ? "关联" : "取消关联"}「${mcpName}」`);
load();
} catch (e) {
// roll back on error
setDetailMcps(detailMcps);
toast.error("操作失败:" + (e as Error).message);
}
}
async function toggleVisibility(skillName: string, agentId: string, agentName: string) {
const on = (visibility[skillName] ?? []).includes(agentId);
try {
await api.toggleSkillVisibility(agentId, skillName, !on);
toast.success(`${on ? "取消" : "授予"}「${agentName}」可见`);
const ids = await api.skillVisibility(skillName);
setVisibility((v) => ({ ...v, [skillName]: ids }));
} catch (e) {
toast.error("操作失败:" + (e as Error).message);
}
}
async function createNewSkill() {
if (!newName.trim()) { toast.error("请填写 name"); return; }
if (!newDesc.trim()) { toast.error("description 为必填项"); return; }
setCreatingSkill(true);
try {
const name = newName.trim();
await api.createSkill({
name, description: newDesc.trim(),
license: newLicense.trim() || undefined,
compatibility: newCompat.trim() || undefined,
mcps: newMcps.length ? newMcps : undefined,
instructions: newInst.trim() || undefined,
});
// apply initial visibility (fire-and-forget per agent; best-effort)
await Promise.all(newVisibility.map((id) => api.toggleSkillVisibility(id, name, true)));
toast.success("已创建 Skill");
setNewOpen(false);
setNewName(""); setNewDesc(""); setNewLicense(""); setNewCompat(""); setNewInst("");
setNewMcps([]); setNewVisibility([]);
load();
} catch (e) {
toast.error("创建失败:" + (e as Error).message);
} finally { setCreatingSkill(false); }
}
// ── Inline input JSX helper (NOT a React component — avoids remount on re-render) ──
// Defined as a plain function returning JSX so React never sees a new component type.
function inlineInputJSX(indent: number) {
return (
<div
key="__inline_create__"
className="flex items-center gap-1 py-0.5 pr-2"
style={{ paddingLeft: indent }}
>
{creating?.kind === "dir"
? <FolderIcon className="size-3.5 shrink-0 text-amber-500" />
: <FileTextIcon className="size-3.5 shrink-0 text-muted-foreground" />
}
<Input
ref={inlineRef}
className="h-6 flex-1 px-1 py-0 font-mono text-xs"
placeholder={creating?.kind === "dir" ? "folder-name" : "filename.py"}
value={newEntryName}
onChange={(e) => setNewEntryName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { cancelRef.current = false; void commitCreate(); }
if (e.key === "Escape") cancelCreate();
}}
onBlur={() => { void commitCreate(); }}
/>
</div>
);
}
// ── Recursive tree renderer ───────────────────────────────────────────────
function renderTree(nodes: TreeNode[], skill: string, depth: number): React.ReactNode {
const baseIndent = 8 + depth * 14;
return nodes.map((node) => {
if (node.type === "dir") {
const key = `${skill}:${node.path}`;
const open = expanded.has(key);
return (
<div key={node.path}>
<div
className="group flex cursor-pointer select-none items-center gap-1 rounded py-0.5 pr-1 text-sm hover:bg-muted"
style={{ paddingLeft: baseIndent }}
onClick={() => toggleExpanded(key)}
>
<ChevronRightIcon className={cn("size-3.5 shrink-0 transition-transform", open && "rotate-90")} />
{open
? <FolderOpenIcon className="size-3.5 shrink-0 text-amber-500" />
: <FolderIcon className="size-3.5 shrink-0 text-amber-500" />
}
<span className="flex-1 truncate">{node.name}</span>
<span className="ml-auto hidden shrink-0 items-center gap-0.5 group-hover:flex">
<Button size="icon" variant="ghost" className="size-5" title="新建文件"
onClick={(e) => { e.stopPropagation(); startCreate(skill, node.path, "file"); }}>
<FilePlusIcon className="size-3 text-muted-foreground" />
</Button>
<Button size="icon" variant="ghost" className="size-5" title="新建文件夹"
onClick={(e) => { e.stopPropagation(); startCreate(skill, node.path, "dir"); }}>
<FolderPlusIcon className="size-3 text-muted-foreground" />
</Button>
<Button size="icon" variant="ghost" className="size-5" title="删除文件夹"
onClick={(e) => { e.stopPropagation(); deletePath(skill, node.path); }}>
<Trash2Icon className="size-3 text-destructive" />
</Button>
</span>
</div>
{open && (
<>
{renderTree(node.children, skill, depth + 1)}
{creating?.skill === skill && creating.inDir === node.path &&
inlineInputJSX(baseIndent + 14)}
</>
)}
</div>
);
}
// file node
const isSelected = selected?.skill === skill && selected.path === node.path;
return (
<div
key={node.path}
className={cn(
"group flex cursor-pointer select-none items-center gap-1 rounded py-0.5 pr-1 text-sm",
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted",
)}
style={{ paddingLeft: baseIndent + 16 }}
onClick={() => setSelected({ skill, path: node.path })}
>
<FileTextIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate font-mono text-xs">{node.name}</span>
<Button size="icon" variant="ghost" className="ml-auto hidden size-5 group-hover:flex"
title="删除文件"
onClick={(e) => { e.stopPropagation(); deletePath(skill, node.path); }}>
<Trash2Icon className="size-3 text-destructive" />
</Button>
</div>
);
});
}
const selectedSkill = selected
? skills.find((s) => s.name === selected.skill) ?? null
: null;
return (
<div data-content-padding="false" className="flex flex-1 flex-col overflow-hidden">
<div className="flex flex-col gap-0.5 border-b px-4 py-2.5 lg:px-6">
<h1 className="text-sm font-semibold leading-tight">Skill</h1>
<p className="text-muted-foreground text-xs">技能库 · agentskills.io 规范 · 按 Agent 授权可见</p>
</div>
<div className="flex flex-1 overflow-hidden">
{/* ── 左侧文件树 ── */}
<div className="flex w-64 shrink-0 flex-col border-r">
<div className="flex flex-col gap-2 border-b p-2">
<Button size="sm" variant="outline" className="w-full" onClick={() => setNewOpen(true)}>
<PlusIcon className="size-3.5" />新建 Skill
</Button>
<input
ref={uploadRef}
type="file"
accept=".zip,application/zip"
className="hidden"
onChange={onUploadPick}
/>
<Button
size="sm"
variant="outline"
className="w-full"
disabled={uploading}
onClick={() => uploadRef.current?.click()}
title="上传包含 SKILL.md 的 .zip 压缩包"
>
<UploadIcon className="size-3.5" />
{uploading ? "上传中…" : "上传压缩包"}
</Button>
</div>
<ScrollArea className="flex-1">
<div className="p-1">
{skills.map((s) => {
const isOpen = expanded.has(s.name);
const tree = buildTree(s.files);
const isSkillSelected = selected?.skill === s.name && selected.path === null;
return (
<div key={s.name}>
{/* skill 根节点 */}
<div
className={cn(
"group flex cursor-pointer select-none items-center gap-1 rounded px-2 py-1 text-sm",
isSkillSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted",
)}
onClick={() => {
toggleExpanded(s.name);
setSelected({ skill: s.name, path: null });
}}
>
<ChevronRightIcon className={cn("size-3.5 shrink-0 transition-transform", isOpen && "rotate-90")} />
{isOpen
? <FolderOpenIcon className="size-3.5 shrink-0 text-blue-500" />
: <FolderIcon className="size-3.5 shrink-0 text-blue-500" />
}
<span className="flex-1 truncate font-semibold">{s.name}</span>
<span className="ml-auto hidden shrink-0 items-center gap-0.5 group-hover:flex">
<Button size="icon" variant="ghost" className="size-5" title="新建文件"
onClick={(e) => { e.stopPropagation(); startCreate(s.name, "", "file"); }}>
<FilePlusIcon className="size-3 text-muted-foreground" />
</Button>
<Button size="icon" variant="ghost" className="size-5" title="新建文件夹"
onClick={(e) => { e.stopPropagation(); startCreate(s.name, "", "dir"); }}>
<FolderPlusIcon className="size-3 text-muted-foreground" />
</Button>
<Button size="icon" variant="ghost" className="size-5" title="删除 Skill"
onClick={(e) => { e.stopPropagation(); deleteSkill(s.name); }}>
<Trash2Icon className="size-3 text-destructive" />
</Button>
</span>
</div>
{/* 展开:递归文件树 */}
{isOpen && (
<>
{renderTree(tree, s.name, 0)}
{creating?.skill === s.name && creating.inDir === "" &&
inlineInputJSX(22)}
</>
)}
</div>
);
})}
{skills.length === 0 && (
<p className="p-3 text-xs text-muted-foreground">暂无 Skill,点击「新建」开始</p>
)}
</div>
</ScrollArea>
</div>
{/* ── 右侧面板 ── */}
<div className="flex flex-1 flex-col overflow-auto p-4">
{!selected && (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">选择左侧 Skill 或文件</p>
</div>
)}
{selected && selected.path === null && selectedSkill && (
<div className="max-w-xl space-y-5">
<div>
<h2 className="font-mono text-base font-semibold">{selectedSkill.name}</h2>
{selectedSkill.description && (
<p className="mt-1 text-sm text-muted-foreground">{selectedSkill.description}</p>
)}
<div className="mt-2 flex flex-wrap gap-2">
{selectedSkill.license && (
<Badge variant="outline" className="text-xs font-normal">License: {selectedSkill.license}</Badge>
)}
{selectedSkill.compatibility && (
<Badge variant="secondary" className="text-xs font-normal">{selectedSkill.compatibility}</Badge>
)}
</div>
</div>
{/* ── 关联 MCP ── */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
关联 MCP
<span className="ml-1 font-normal">(加载 Skill 时才披露/解锁其工具)</span>
</Label>
{mcpOptions.length === 0 ? (
<p className="text-xs text-muted-foreground">暂无 MCP,可在「MCP」页添加。</p>
) : (
<div className="flex flex-wrap gap-x-4 gap-y-2">
{mcpOptions.map((m) => (
<label key={m.id} className="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={detailMcps.includes(m.name)}
onCheckedChange={(on) => toggleSkillMcp(selectedSkill.name, m.name, !!on)}
/>
{m.name}
</label>
))}
</div>
)}
</div>
{/* ── 可见性 ── */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">可见性(按 Agent 授权)</Label>
<div className="space-y-2">
{agents.map((a) => (
<label key={a.key} className="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={(visibility[selectedSkill.name] ?? []).includes(a.id)}
onCheckedChange={() => toggleVisibility(selectedSkill.name, a.id, a.name)}
/>
{a.name}
</label>
))}
{agents.length === 0 && (
<span className="text-xs text-muted-foreground">(暂无 Agent)</span>
)}
</div>
</div>
</div>
)}
{selected && selected.path !== null && (
<div className="flex h-full flex-col gap-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground">{selected.skill}</span>
<span>/</span>
<span className="font-mono">{selected.path}</span>
<Button size="sm" className="ml-auto" onClick={saveFile} disabled={!dirty || saving}>
{saving ? "保存中…" : "保存"}
</Button>
</div>
{fileLoading ? (
<p className="text-xs text-muted-foreground">加载中…</p>
) : (
<Textarea
className="flex-1 resize-none font-mono text-xs"
value={fileContent}
onChange={(e) => { setFileContent(e.target.value); setDirty(true); }}
/>
)}
</div>
)}
</div>
</div>
{/* ── 新建 Skill 对话框 ── */}
<Sheet open={newOpen} onOpenChange={setNewOpen}>
<SheetContent side="right" className="flex w-full flex-col gap-0 data-[side=right]:sm:max-w-xl">
<SheetHeader className="px-4 pt-4">
<SheetTitle>新建 Skill</SheetTitle>
</SheetHeader>
<Tabs defaultValue="basic" className="flex min-h-0 flex-1 flex-col">
<TabsList className="mx-4 mt-3 shrink-0 justify-start">
<TabsTrigger value="basic">基本信息</TabsTrigger>
<TabsTrigger value="mcp">
关联 MCP
{newMcps.length > 0 && (
<span className="ml-1.5 rounded-full bg-primary px-1.5 py-0.5 text-[10px] leading-none text-primary-foreground">
{newMcps.length}
</span>
)}
</TabsTrigger>
<TabsTrigger value="visibility">
可见性
{newVisibility.length > 0 && (
<span className="ml-1.5 rounded-full bg-primary px-1.5 py-0.5 text-[10px] leading-none text-primary-foreground">
{newVisibility.length}
</span>
)}
</TabsTrigger>
</TabsList>
{/* 基本信息 */}
<TabsContent value="basic" className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4 pt-4 data-[state=inactive]:hidden">
<div className="grid gap-1.5">
<Label htmlFor="sk-name">名称 <span className="text-destructive">*</span></Label>
<Input id="sk-name" placeholder="sqli-deepdive" value={newName} onChange={(e) => setNewName(e.target.value)} />
<p className="text-muted-foreground text-xs">小写字母 / 数字 / 连字符,1–64 字符</p>
</div>
<div className="grid gap-1.5">
<Label htmlFor="sk-desc">描述 <span className="text-destructive">*</span></Label>
<Textarea id="sk-desc" rows={2} className="resize-none"
placeholder="这个 skill 做什么、何时使用。"
value={newDesc} onChange={(e) => setNewDesc(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">license</Label>
<Input placeholder="MIT / Proprietary" value={newLicense} onChange={(e) => setNewLicense(e.target.value)} />
</div>
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">compatibility</Label>
<Input placeholder="需要 sqlmap、python3" value={newCompat} onChange={(e) => setNewCompat(e.target.value)} />
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-1.5">
<Label htmlFor="sk-inst">正文 <span className="text-muted-foreground text-xs font-normal">(留空自动生成骨架)</span></Label>
<Textarea id="sk-inst"
className="min-h-40 flex-1 resize-none font-mono text-sm leading-relaxed"
placeholder={"## 执行方法\n\n1. 先探测错误\n2. 区分盲注类型\n\n脚本放 scripts/ 目录。"}
value={newInst} onChange={(e) => setNewInst(e.target.value)} />
</div>
</TabsContent>
{/* 关联 MCP */}
<TabsContent value="mcp" className="overflow-y-auto px-4 pb-4 pt-4 data-[state=inactive]:hidden">
<p className="mb-3 text-xs text-muted-foreground">加载 Skill 时才披露并解锁所选 MCP 的工具。</p>
{mcpOptions.length === 0 ? (
<p className="text-xs text-muted-foreground">暂无 MCP,可在「MCP」页添加。</p>
) : (
<div className="flex flex-col gap-2">
{mcpOptions.map((m) => (
<label key={m.id} className="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={newMcps.includes(m.name)}
onCheckedChange={(on) =>
setNewMcps((cur) => on ? [...cur, m.name] : cur.filter((n) => n !== m.name))
}
/>
{m.name}
</label>
))}
</div>
)}
</TabsContent>
{/* 可见性 */}
<TabsContent value="visibility" className="overflow-y-auto px-4 pb-4 pt-4 data-[state=inactive]:hidden">
<p className="mb-3 text-xs text-muted-foreground">选中的 Agent 创建后即可见此 Skill。</p>
{agents.length === 0 ? (
<p className="text-xs text-muted-foreground">(暂无 Agent)</p>
) : (
<div className="flex flex-col gap-2">
{agents.map((a) => (
<label key={a.key} className="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={newVisibility.includes(a.id)}
onCheckedChange={(on) =>
setNewVisibility((cur) => on ? [...cur, a.id] : cur.filter((id) => id !== a.id))
}
/>
{a.name}
</label>
))}
</div>
)}
</TabsContent>
</Tabs>
<SheetFooter className="flex-row justify-end gap-2 border-t px-4 py-3">
<Button variant="outline" onClick={() => setNewOpen(false)}>取消</Button>
<Button onClick={createNewSkill} disabled={creatingSkill}>{creatingSkill ? "创建中…" : "创建"}</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
);
}
+835
View File
@@ -0,0 +1,835 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import { PlayIcon, PlusIcon, RotateCcwIcon, SaveIcon, SearchIcon, Trash2Icon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { api } from "@/lib/api";
import type { Agent, Tool } from "@/lib/types";
// Traffic tools are host tools gated by the global 流量捕获 switch: bindable, but
// only usable when capture is on. Keep in sync with traffic.SeedToolMetas.
const TRAFFIC_TOOL_KEYS = new Set(["traffic_search", "traffic_get"]);
// paramRows flattens schema.properties into an ordered row list for editing.
// name/type/required are read-only (welded to the Go handler); description/default
// are editable. Non-scalar params (array/object) can't carry an editable default.
// parentKey is set for rows that live inside an array param's items.properties.
type ParamRow = {
name: string;
type: string;
required: boolean;
description: string;
defaultStr: string; // "" = no default declared
scalar: boolean;
parentKey?: string; // set when this is a sub-field of an array param's items
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function toRows(schema: Record<string, any>): ParamRow[] {
const props = (schema?.properties ?? {}) as Record<string, Record<string, unknown>>;
const required = new Set<string>((schema?.required as string[]) ?? []);
const rows: ParamRow[] = [];
for (const [name, p] of Object.entries(props)) {
const type = String(p?.type ?? "");
const hasDefault = p != null && "default" in p && (p as Record<string, unknown>).default != null;
rows.push({
name, type,
required: required.has(name),
description: String(p?.description ?? ""),
defaultStr: hasDefault ? String((p as Record<string, unknown>).default) : "",
scalar: ["string", "integer", "number", "boolean"].includes(type),
});
// Expand items.properties for array params so sub-fields are editable.
if (type === "array") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const itemProps = (p as any)?.items?.properties as Record<string, Record<string, unknown>> | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const itemRequired = new Set<string>(((p as any)?.items?.required as string[]) ?? []);
if (itemProps) {
for (const [subName, subP] of Object.entries(itemProps)) {
const subType = String((subP as Record<string, unknown>)?.type ?? "");
const subHasDefault = subP != null && "default" in subP && subP.default != null;
rows.push({
name: subName, type: subType,
required: itemRequired.has(subName),
description: String(subP?.description ?? ""),
defaultStr: subHasDefault ? String(subP.default) : "",
scalar: ["string", "integer", "number", "boolean"].includes(subType),
parentKey: name,
});
}
}
}
}
return rows;
}
// coerceDefault turns the edited default string back into a typed JSON value, or
// undefined to drop the "default" key entirely.
function coerceDefault(type: string, raw: string): unknown {
const s = raw.trim();
if (s === "") return undefined;
if (type === "integer" || type === "number") {
const n = Number(s);
return Number.isNaN(n) ? undefined : n;
}
if (type === "boolean") return s === "true";
return raw;
}
// applyRows writes edited rows back into a deep-copied schema (structure untouched).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function applyRows(schema: Record<string, any>, rows: ParamRow[]): Record<string, any> {
const next = structuredClone(schema ?? {});
const props = (next.properties ?? {}) as Record<string, Record<string, unknown>>;
for (const r of rows) {
if (r.parentKey) {
// Sub-field of an array param's items.properties
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const parent = props[r.parentKey] as any;
const subP = parent?.items?.properties?.[r.name] as Record<string, unknown> | undefined;
if (!subP) continue;
subP.description = r.description;
if (r.scalar) {
const dv = coerceDefault(r.type, r.defaultStr);
if (dv === undefined) delete subP.default;
else subP.default = dv;
}
} else {
const p = props[r.name];
if (!p) continue;
p.description = r.description;
if (r.scalar) {
const dv = coerceDefault(r.type, r.defaultStr);
if (dv === undefined) delete p.default;
else p.default = dv;
}
}
}
return next;
}
// ToolEditor is the full edit form for one tool, rendered inside the drawer.
function ToolEditor({
tool,
agents,
captureOn,
onSaved,
onClose,
}: {
tool: Tool;
agents: Agent[];
captureOn: boolean;
onSaved: () => void;
onClose: () => void;
}) {
// traffic tools can't be bound/enabled until the global 流量捕获 switch is on.
const trafficGated = TRAFFIC_TOOL_KEYS.has(tool.key) && !captureOn;
const [description, setDescription] = React.useState(tool.description);
const [bound, setBound] = React.useState<string[]>(tool.agents);
const [enabled, setEnabled] = React.useState(tool.enabled);
const [rows, setRows] = React.useState<ParamRow[]>(() => toRows(tool.schema));
const [saving, setSaving] = React.useState(false);
const setRow = (i: number, patch: Partial<ParamRow>) =>
setRows((rs) => rs.map((r, j) => (j === i ? { ...r, ...patch } : r)));
const toggleAgent = (k: string) =>
setBound((b) => (b.includes(k) ? b.filter((x) => x !== k) : [...b, k]));
async function save() {
setSaving(true);
try {
await api.saveTool(tool.key, {
description,
schema: applyRows(tool.schema, rows),
agents: bound,
enabled,
});
toast.success(`已保存工具「${tool.key}」`);
onSaved();
onClose();
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
} finally {
setSaving(false);
}
}
async function reset() {
try {
await api.resetTool(tool.key);
toast.success(`已恢复「${tool.key}」为代码默认`);
onSaved();
onClose();
} catch (e) {
toast.error("恢复失败:" + (e as Error).message);
}
}
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4">
{trafficGated && (
<div className="border-amber-500/40 bg-amber-500/10 text-muted-foreground rounded-md border px-3 py-2 text-xs">
该工具依赖<b>流量捕获</b>。请先在「系统配置」开启流量捕获,才能绑定给 Agent 并启用。
</div>
)}
{/* binding + switch */}
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">绑定 Agent(决定该工具给哪些 Agent)</Label>
<div className="flex flex-wrap gap-3">
{agents.map((ag) => (
<label key={ag.key} className="flex items-center gap-2 text-sm">
<Checkbox
checked={bound.includes(ag.key)}
disabled={trafficGated}
onCheckedChange={() => toggleAgent(ag.key)}
/>
{ag.name}
<span className="text-muted-foreground font-mono text-xs">{ag.key}</span>
</label>
))}
{agents.length === 0 && <span className="text-muted-foreground text-xs">(无 Agent)</span>}
</div>
</div>
<div className="flex items-center gap-2">
<Switch checked={enabled} onCheckedChange={setEnabled} id={`en-${tool.key}`} />
<Label htmlFor={`en-${tool.key}`} className="text-sm">
启用
</Label>
</div>
</div>
{/* description */}
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">工具描述(发送给模型)</Label>
<Textarea
className="font-mono text-xs"
rows={6}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
{/* params */}
<div className="grid gap-2">
<Label className="text-muted-foreground text-xs">
参数(名称 / 类型 / 必填只读;描述与默认值可改)
</Label>
{rows.length === 0 && <span className="text-muted-foreground text-xs">(无参数)</span>}
{rows.map((r, i) => (
<div
key={(r.parentKey ?? "") + "." + r.name}
className={r.parentKey
? "border-l-2 border-muted ml-3 pl-3 grid gap-2 py-2"
: "grid gap-2 rounded-md border p-3"
}
>
<div className="flex flex-wrap items-center gap-2">
{r.parentKey && <span className="text-muted-foreground font-mono text-[10px]">↳</span>}
<span className="font-mono text-sm">{r.name}</span>
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
{r.type || "?"}
</Badge>
{r.required && (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
必填
</Badge>
)}
{r.parentKey && (
<span className="text-muted-foreground text-[10px]">items 子字段</span>
)}
</div>
<div className="grid gap-2">
<div className="grid gap-1">
<Label className="text-muted-foreground text-[11px]">描述</Label>
<Input
className="text-xs"
value={r.description}
onChange={(e) => setRow(i, { description: e.target.value })}
/>
</div>
<div className="grid gap-1">
<Label className="text-muted-foreground text-[11px]">默认值</Label>
<Input
className="text-xs"
placeholder={r.scalar ? "(空=无默认)" : "仅标量支持"}
disabled={!r.scalar}
value={r.defaultStr}
onChange={(e) => setRow(i, { defaultStr: e.target.value })}
/>
</div>
</div>
</div>
))}
</div>
</div>
<Separator className="mt-4" />
<div className="flex flex-wrap gap-2 p-4">
<Button size="sm" onClick={save} disabled={saving}>
<SaveIcon /> 保存
</Button>
<Button size="sm" variant="outline" onClick={reset}>
<RotateCcwIcon /> 恢复默认
</Button>
</div>
</div>
);
}
// ToolGridCard is one clickable tile in the catalog grid.
function ToolGridCard({ tool, onClick }: { tool: Tool; onClick: () => void }) {
return (
<button
onClick={onClick}
className="hover:border-primary/50 hover:bg-muted/40 focus-visible:ring-ring flex flex-col gap-2 rounded-lg border p-4 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm font-medium">{tool.key}</span>
{tool.system ? (
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
系统
</Badge>
) : (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
自定义·{tool.kind}
</Badge>
)}
{tool.deferred && (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
deferred
</Badge>
)}
{!tool.enabled && (
<Badge variant="outline" className="text-destructive px-1.5 py-0 text-[10px]">
已停用
</Badge>
)}
</div>
<p className="text-muted-foreground line-clamp-1 h-4 text-xs">
{tool.description || "(无描述)"}
</p>
<div className="mt-auto flex flex-wrap gap-1 pt-1">
{tool.agents.length === 0 && (
<span className="text-muted-foreground text-[10px]">(未绑定 Agent)</span>
)}
{tool.agents.map((a) => (
<Badge key={a} variant="outline" className="px-1.5 py-0 text-[10px]">
{a}
</Badge>
))}
</div>
</button>
);
}
export default function ToolsPage() {
const [tools, setTools] = React.useState<Tool[]>([]);
const [agents, setAgents] = React.useState<Agent[]>([]);
const [captureOn, setCaptureOn] = React.useState(false);
const [selectedKey, setSelectedKey] = React.useState<string | null>(null);
const [customEdit, setCustomEdit] = React.useState<Tool | "new" | null>(null);
const reload = React.useCallback(() => {
api.tools().then(setTools).catch(() => setTools([]));
}, []);
React.useEffect(() => {
reload();
api.agents().then(setAgents).catch(() => {});
api.settings().then((s) => setCaptureOn(!!s.traffic_capture)).catch(() => {});
}, [reload]);
const [query, setQuery] = React.useState("");
const selected = tools.find((t) => t.key === selectedKey) ?? null;
const matchTool = React.useCallback((t: Tool) => {
const q = query.trim().toLowerCase();
if (!q) return true;
return (
t.key.toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q) ||
t.agents.some((a) => a.toLowerCase().includes(q))
);
}, [query]);
const systemTools = tools.filter((t) => t.system && matchTool(t));
const customTools = tools.filter((t) => !t.system && matchTool(t));
const allSystemCount = tools.filter((t) => t.system).length;
const allCustomCount = tools.filter((t) => !t.system).length;
// clicking a built-in tool opens the binding drawer; a custom tool opens its full editor.
const openTool = (t: Tool) => (t.system ? setSelectedKey(t.key) : setCustomEdit(t));
return (
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold tracking-tight">工具</h1>
<p className="text-muted-foreground text-sm">系统工具的描述/绑定,以及自定义工具(command/script/http)</p>
</div>
<div className="relative w-64">
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2" />
<Input
className="h-8 pl-8 text-sm"
placeholder="搜索工具名、描述、Agent…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
</div>
<Tabs defaultValue="system">
<TabsList>
<TabsTrigger value="system">系统工具</TabsTrigger>
<TabsTrigger value="custom">自定义工具</TabsTrigger>
</TabsList>
<TabsContent value="system">
<Card>
<CardHeader>
<CardTitle>系统工具</CardTitle>
<CardDescription>
{query.trim()
? `${systemTools.length} / ${allSystemCount} 个匹配,点击卡片编辑描述、参数默认值与 Agent 绑定`
: `共 ${allSystemCount} 个,点击卡片编辑描述、参数默认值与 Agent 绑定`}
</CardDescription>
</CardHeader>
<CardContent>
{systemTools.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
{query.trim() ? "没有匹配的系统工具" : "(暂无系统工具,等待后端 seed)"}
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{systemTools.map((t) => (
<ToolGridCard key={t.key} tool={t} onClick={() => openTool(t)} />
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="custom">
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>自定义工具</CardTitle>
<CardDescription>
{query.trim()
? `shell/command/script/http,${customTools.length} / ${allCustomCount} 个匹配,点击卡片编辑`
: `shell(bash 声明) / command(命令) / script(Python) / http(API),共 ${allCustomCount} 个,点击卡片编辑`}
</CardDescription>
</div>
<Button size="sm" onClick={() => setCustomEdit("new")}>
<PlusIcon /> 新建自定义工具
</Button>
</div>
</CardHeader>
<CardContent>
{customTools.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
{query.trim() ? "没有匹配的自定义工具" : "(暂无自定义工具,点击右上角「新建自定义工具」)"}
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{customTools.map((t) => (
<ToolGridCard key={t.key} tool={t} onClick={() => openTool(t)} />
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
<Sheet open={!!selected} onOpenChange={(o) => !o && setSelectedKey(null)}>
<SheetContent
side="right"
className="gap-0 p-0 data-[side=right]:w-[30vw] data-[side=right]:sm:max-w-[30vw]"
>
{selected && (
<>
<SheetHeader className="px-4">
<SheetTitle className="font-mono">{selected.key}</SheetTitle>
<SheetDescription>编辑描述、参数默认值与 Agent 绑定</SheetDescription>
</SheetHeader>
<ToolEditor
key={selected.key}
tool={selected}
agents={agents}
captureOn={captureOn}
onSaved={reload}
onClose={() => setSelectedKey(null)}
/>
</>
)}
</SheetContent>
</Sheet>
<CustomToolDialog
edit={customEdit}
agents={agents}
onClose={() => setCustomEdit(null)}
onSaved={() => {
setCustomEdit(null);
reload();
}}
/>
</div>
);
}
// ---- 自定义工具编辑器 ----
type ExecState = {
command: string;
code: string;
method: string;
url: string;
headers: string;
body: string;
timeout_ms: string;
proxy: string;
use_recording_proxy: boolean;
};
function CustomToolDialog({
edit,
agents,
onClose,
onSaved,
}: {
edit: Tool | "new" | null;
agents: Agent[];
onClose: () => void;
onSaved: () => void;
}) {
const isNew = edit === "new";
const tool = edit && edit !== "new" ? edit : null;
const [key, setKey] = React.useState("");
const [description, setDescription] = React.useState("");
const [kind, setKind] = React.useState<"shell" | "command" | "script" | "http">("shell");
const [ex, setEx] = React.useState<ExecState>({
command: "", code: "", method: "GET", url: "", headers: "", body: "", timeout_ms: "", proxy: "", use_recording_proxy: false,
});
const [schemaText, setSchemaText] = React.useState("");
const [bound, setBound] = React.useState<string[]>([]);
const [deferred, setDeferred] = React.useState(false);
const [enabled, setEnabled] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [paramsText, setParamsText] = React.useState("");
const [testing, setTesting] = React.useState(false);
const [testResult, setTestResult] = React.useState<{ output: string; is_error: boolean } | null>(null);
// (re)load form state when opening.
React.useEffect(() => {
if (!edit) return;
setParamsText(""); setTestResult(null);
if (edit === "new") {
setKey(""); setDescription(""); setKind("shell");
setEx({ command: "", code: "", method: "GET", url: "", headers: "", body: "", timeout_ms: "", proxy: "", use_recording_proxy: false });
setSchemaText(""); setBound([]); setDeferred(false); setEnabled(true);
return;
}
const t = edit;
const e = (t.exec ?? {}) as Record<string, unknown>;
setKey(t.key);
setDescription(t.description);
setKind((t.kind as "shell" | "command" | "script" | "http") ?? "shell");
setEx({
command: String(e.command ?? ""),
code: String(e.code ?? ""),
method: String(e.method ?? "GET"),
url: String(e.url ?? ""),
headers: e.headers ? JSON.stringify(e.headers, null, 2) : "",
body: String(e.body ?? ""),
timeout_ms: e.timeout_ms ? String(e.timeout_ms) : "",
proxy: String(e.proxy ?? ""),
use_recording_proxy: !!e.use_recording_proxy,
});
setSchemaText(t.schema && Object.keys(t.schema).length ? JSON.stringify(t.schema, null, 2) : "");
setBound(t.agents ?? []);
setDeferred(!!t.deferred);
setEnabled(t.enabled);
}, [edit]);
const toggleAgent = (k: string) => setBound((b) => (b.includes(k) ? b.filter((x) => x !== k) : [...b, k]));
function buildExec(): Record<string, unknown> {
if (kind === "shell") return {};
const t = ex.timeout_ms ? Number(ex.timeout_ms) : undefined;
if (kind === "command") return { command: ex.command, timeout_ms: t };
if (kind === "script") return { code: ex.code, timeout_ms: t };
let headers: Record<string, string> = {};
if (ex.headers.trim()) {
try { headers = JSON.parse(ex.headers); } catch { /* validated on save */ }
}
return { method: ex.method, url: ex.url, headers, body: ex.body, timeout_ms: t, proxy: ex.proxy, use_recording_proxy: ex.use_recording_proxy };
}
async function save() {
if (isNew && !/^[a-z][a-z0-9_]*$/.test(key.trim())) {
toast.error("key 需小写字母开头,仅含小写字母/数字/下划线");
return;
}
let schema: Record<string, unknown> = {};
if (schemaText.trim()) {
try { schema = JSON.parse(schemaText); } catch { toast.error("参数 JSON Schema 格式错误"); return; }
}
if (kind === "http" && ex.headers.trim()) {
try { JSON.parse(ex.headers); } catch { toast.error("headers JSON 格式错误"); return; }
}
if (kind === "http") {
const props = (schema as { properties?: Record<string, unknown> }).properties;
if (!props || Object.keys(props).length === 0) {
toast.error("http 工具必须提供参数 JSON Schema(含 properties,不能留空)");
return;
}
}
setSaving(true);
const payload = { description, schema: kind === "shell" ? {} : schema, agents: bound, enabled, kind, exec: buildExec(), deferred: kind === "shell" ? false : deferred };
try {
if (isNew) await api.createCustomTool({ key: key.trim(), ...payload });
else await api.updateCustomTool(tool!.key, payload);
toast.success(isNew ? "已创建自定义工具" : "已保存");
onSaved();
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
} finally {
setSaving(false);
}
}
async function del() {
if (!tool) return;
try {
await api.deleteCustomTool(tool.key);
toast.success("已删除");
onSaved();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}
// runTest dry-runs the CURRENT form (unsaved) with the sample params, so a
// template/script/request can be debugged before saving.
async function runTest() {
let params: Record<string, unknown> = {};
if (paramsText.trim()) {
try { params = JSON.parse(paramsText); } catch { toast.error("测试参数 JSON 格式错误"); return; }
}
if (kind === "http" && ex.headers.trim()) {
try { JSON.parse(ex.headers); } catch { toast.error("headers JSON 格式错误"); return; }
}
setTesting(true);
setTestResult(null);
try {
const r = await api.testCustomTool({ kind, exec: buildExec(), params });
setTestResult(r);
} catch (e) {
setTestResult({ output: (e as Error).message, is_error: true });
} finally {
setTesting(false);
}
}
return (
<Sheet open={!!edit} onOpenChange={(o) => !o && onClose()}>
<SheetContent
side="right"
className="flex flex-col gap-0 p-0 data-[side=right]:w-[45vw] data-[side=right]:sm:max-w-[45vw] data-[side=right]:min-w-[480px]"
>
<SheetHeader className="px-4">
<SheetTitle>{isNew ? "新建自定义工具" : `编辑 ${tool?.key}`}</SheetTitle>
<SheetDescription>shell=bash 环境声明(只需名称+描述,告知模型可用 bash 调用);command/script/http 需写执行规格。</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4">
<div className="grid gap-1.5">
<Label className="text-xs">Key</Label>
<Input className="font-mono" placeholder="如 nmap_scan" value={key} disabled={!isNew}
onChange={(e) => setKey(e.target.value)} />
</div>
<div className="grid gap-1.5">
<Label className="text-xs">描述(发给模型)</Label>
<Textarea rows={2} value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<div className="grid gap-1.5">
<Label className="text-xs">类型</Label>
<Select value={kind} onValueChange={(v) => setKind(v as "shell" | "command" | "script" | "http")}>
<SelectTrigger className="w-56"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="shell">shell(bash 环境声明)</SelectItem>
<SelectItem value="command">command(shell 命令模板)</SelectItem>
<SelectItem value="script">script(Python 脚本)</SelectItem>
<SelectItem value="http">http(API 请求)</SelectItem>
</SelectContent>
</Select>
{kind === "shell" && (
<p className="text-muted-foreground text-xs">适合 nmap、sqlmap、ffuf 等出名工具——模型已知用法,只需声明"可在 bash 中调用"即可。名称+描述会追加到 Bash 工具描述里。</p>
)}
</div>
{kind === "command" && (
<div className="grid gap-1.5">
<Label className="text-xs">命令模板(占位符 {"{param}"},如 nmap -p {"{ports}"} {"{target}"})</Label>
<Textarea className="font-mono text-xs" rows={2} value={ex.command}
onChange={(e) => setEx({ ...ex, command: e.target.value })} />
</div>
)}
{kind === "script" && (
<div className="grid gap-1.5">
<Label className="text-xs">Python 正文(参数走 stdin JSON / os.environ["TOOL_X"])</Label>
<Textarea className="font-mono text-xs" rows={10} value={ex.code}
placeholder={'import json,sys\nargs=json.load(sys.stdin)\nprint(...)'}
onChange={(e) => setEx({ ...ex, code: e.target.value })} />
</div>
)}
{kind === "http" && (
<div className="grid gap-2">
<div className="flex gap-2">
<div className="grid gap-1.5">
<Label className="text-xs">Method</Label>
<Input className="w-24" value={ex.method} onChange={(e) => setEx({ ...ex, method: e.target.value })} />
</div>
<div className="grid flex-1 gap-1.5">
<Label className="text-xs">URL(可含 {"{param}"})</Label>
<Input className="font-mono text-xs" value={ex.url} onChange={(e) => setEx({ ...ex, url: e.target.value })} />
</div>
</div>
<div className="grid gap-1.5">
<Label className="text-xs">Headers(JSON,可含 {"{param}"})</Label>
<Textarea className="font-mono text-xs" rows={2} value={ex.headers}
placeholder={'{"Authorization": "Bearer {token}"}'} onChange={(e) => setEx({ ...ex, headers: e.target.value })} />
</div>
<div className="grid gap-1.5">
<Label className="text-xs">Body(可含 {"{param}"})</Label>
<Textarea className="font-mono text-xs" rows={2} value={ex.body} onChange={(e) => setEx({ ...ex, body: e.target.value })} />
</div>
<div className="flex items-center gap-4">
<div className="grid gap-1.5">
<Label className="text-xs">代理 URL(空=直连)</Label>
<Input className="font-mono text-xs w-56" value={ex.proxy} onChange={(e) => setEx({ ...ex, proxy: e.target.value })} />
</div>
<label className="mt-4 flex items-center gap-2 text-sm">
<Checkbox checked={ex.use_recording_proxy} onCheckedChange={(v) => setEx({ ...ex, use_recording_proxy: !!v })} />
走记录代理
</label>
</div>
</div>
)}
{kind !== "shell" && (
<div className="flex items-center gap-3">
<div className="grid gap-1.5">
<Label className="text-xs">超时(ms,空=默认)</Label>
<Input type="number" className="w-32" value={ex.timeout_ms} onChange={(e) => setEx({ ...ex, timeout_ms: e.target.value })} />
</div>
</div>
)}
{kind !== "shell" && (
<div className="grid gap-1.5">
<Label className="text-xs">
参数 JSON Schema{kind === "http" ? "(http 工具必填,需含 properties)" : "(留空 = 自动给 {args} 薄壳)"}
</Label>
<Textarea className="font-mono text-xs" rows={4} value={schemaText}
placeholder={'{"type":"object","properties":{"target":{"type":"string"}},"required":["target"]}'}
onChange={(e) => setSchemaText(e.target.value)} />
</div>
)}
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">绑定 Agent</Label>
<div className="flex flex-wrap gap-3">
{agents.map((a) => (
<label key={a.key} className="flex items-center gap-2 text-sm">
<Checkbox checked={bound.includes(a.key)} onCheckedChange={() => toggleAgent(a.key)} />
{a.name}<span className="text-muted-foreground font-mono text-xs">{a.key}</span>
</label>
))}
</div>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm">
<Switch checked={enabled} onCheckedChange={setEnabled} /> 启用
</label>
{kind !== "shell" && (
<label className="flex items-center gap-2 text-sm">
<Switch checked={deferred} onCheckedChange={setDeferred} /> deferred(大量不常用工具才开)
</label>
)}
</div>
{kind !== "shell" && (
<div className="grid gap-1.5 rounded-md border p-3">
<Label className="text-xs font-medium">测试运行(用当前表单,不会保存)</Label>
<Textarea className="font-mono text-xs" rows={2} value={paramsText}
placeholder={'示例参数 JSON,如 {"target":"example.com"}'}
onChange={(e) => setParamsText(e.target.value)} />
<div>
<Button size="sm" variant="outline" onClick={runTest} disabled={testing}>
<PlayIcon /> {testing ? "运行中…" : "测试运行"}
</Button>
</div>
{testResult && (
<pre
className={
"max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-muted p-2 font-mono text-xs " +
(testResult.is_error ? "text-destructive" : "")
}
>
{testResult.output || "(无输出)"}
</pre>
)}
</div>
)}
</div>
<Separator />
<div className="flex items-center gap-2 p-4">
<Button size="sm" onClick={save} disabled={saving}>
<SaveIcon /> {isNew ? "创建" : "保存"}
</Button>
{!isNew && (
<Button size="sm" variant="outline" className="text-destructive" onClick={del}>
<Trash2Icon /> 删除
</Button>
)}
</div>
</SheetContent>
</Sheet>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+284
View File
@@ -0,0 +1,284 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
/* Theme preset styles: these override CSS variables based on the selected data-theme-preset */
@import "../styles/presets/brutalist.css";
@import "../styles/presets/soft-pop.css";
@import "../styles/presets/tangerine.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-heading: var(--font-sans);
--font-sans: var(--font-sans);
}
/* Default theme styles (used when no data-theme-preset is set or when 'default' is selected).
These serve as the fallback; there is no separate default.css file. */
:root {
--radius: 0.625rem;
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
/* fonts */
--font-sans: var(--font-geist);
--font-mono: var(--font-geist-mono);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
font-family: var(--font-sans), system-ui, sans-serif;
}
html {
@apply font-sans;
}
}
@layer utilities {
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-2xs {
box-shadow: var(--shadow-2xs);
}
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-xs {
box-shadow: var(--shadow-xs);
}
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-sm {
box-shadow: var(--shadow-sm);
}
[data-theme-preset]:not([data-theme-preset="default"]) .shadow {
box-shadow: var(--shadow);
}
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-md {
box-shadow: var(--shadow-md);
}
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-lg {
box-shadow: var(--shadow-lg);
}
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-xl {
box-shadow: var(--shadow-xl);
}
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-2xl {
box-shadow: var(--shadow-2xl);
}
html[data-font="inter"] body {
--font-sans: var(--font-inter);
}
html[data-font="notoSans"] body {
--font-sans: var(--font-noto-sans);
}
html[data-font="nunitoSans"] body {
--font-sans: var(--font-nunito-sans);
}
html[data-font="figtree"] body {
--font-sans: var(--font-figtree);
}
html[data-font="roboto"] body {
--font-sans: var(--font-roboto);
}
html[data-font="geist"] body {
--font-sans: var(--font-geist);
}
html[data-font="raleway"] body {
--font-sans: var(--font-raleway);
}
html[data-font="dmSans"] body {
--font-sans: var(--font-dm-sans);
}
html[data-font="publicSans"] body {
--font-sans: var(--font-public-sans);
}
html[data-font="outfit"] body {
--font-sans: var(--font-outfit);
}
html[data-font="geistMono"] body {
--font-sans: var(--font-geist-mono);
}
html[data-font="geistPixelSquare"] body {
--font-sans: var(--font-geist-pixel-square);
}
html[data-font="jetBrainsMono"] body {
--font-sans: var(--font-jetbrains-mono);
}
html[data-font="notoSerif"] body {
--font-sans: var(--font-noto-serif);
}
html[data-font="robotoSlab"] body {
--font-sans: var(--font-roboto-slab);
}
html[data-font="merriweather"] body {
--font-sans: var(--font-merriweather);
}
html[data-font="lora"] body {
--font-sans: var(--font-lora);
}
html[data-font="playfairDisplay"] body {
--font-sans: var(--font-playfair-display);
}
}
html {
overscroll-behavior: none;
}
.disable-transitions * {
transition: none !important;
}
[data-print-root] {
display: none;
}
@media print {
@page {
size: Letter;
margin: 0;
}
html,
body {
width: 8.5in !important;
height: 11in !important;
margin: 0 !important;
overflow: hidden !important;
}
body > *:not([data-print-root]) {
display: none !important;
}
[data-print-root] {
display: block !important;
width: 8.5in !important;
height: 11in !important;
margin: 0 !important;
overflow: hidden !important;
background: white !important;
}
[data-print-root] [data-print-paper] {
width: 8.5in !important;
height: 11in !important;
margin: 0 !important;
box-shadow: none !important;
}
[data-print-root] [data-print-paper],
[data-print-root] [data-print-paper] * {
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

+55
View File
@@ -0,0 +1,55 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { APP_CONFIG } from "@/config/app-config";
import { fontVars } from "@/lib/fonts/registry";
import { PREFERENCE_DEFAULTS } from "@/lib/preferences/preferences-config";
import { ThemeBootScript } from "@/scripts/theme-boot";
import { PreferencesStoreProvider } from "@/stores/preferences/preferences-provider";
import "./globals.css";
export const metadata: Metadata = {
title: APP_CONFIG.meta.title,
description: APP_CONFIG.meta.description,
};
export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
const { theme_mode, theme_preset, content_layout, navbar_style, sidebar_variant, sidebar_collapsible, font } =
PREFERENCE_DEFAULTS;
return (
<html
lang="en"
data-theme-mode={theme_mode}
data-theme-preset={theme_preset}
data-content-layout={content_layout}
data-navbar-style={navbar_style}
data-sidebar-variant={sidebar_variant}
data-sidebar-collapsible={sidebar_collapsible}
data-font={font}
suppressHydrationWarning
>
<head>
{/* Applies theme and layout preferences on load to avoid flicker and unnecessary server rerenders. */}
<ThemeBootScript />
</head>
<body className={`${fontVars} min-h-screen antialiased`}>
<TooltipProvider>
<PreferencesStoreProvider
themeMode={theme_mode}
themePreset={theme_preset}
contentLayout={content_layout}
navbarStyle={navbar_style}
font={font}
>
{children}
<Toaster />
</PreferencesStoreProvider>
</TooltipProvider>
</body>
</html>
);
}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui/button";
export default function NotFound() {
return (
<div className="flex h-dvh flex-col items-center justify-center space-y-2 text-center">
<h1 className="font-semibold text-2xl">Page not found.</h1>
<p className="text-muted-foreground">The page you are looking for could not be found.</p>
<Link prefetch={false} replace href="/function/tasks">
<Button variant="outline">Go back home</Button>
</Link>
</div>
);
}
+878
View File
@@ -0,0 +1,878 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import { EyeIcon, GitCompareIcon, InfoIcon, RotateCcwIcon, SaveIcon, Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import type { AgentDetail, AgentTrigger, MCPServer, PromptVar, PromptVersion, Settings, SkillItem, Tool } from "@/lib/types";
// Traffic tools are host tools gated by the global 流量捕获 switch: bindable, but
// only usable when capture is on. Keep this list in sync with traffic.SeedToolMetas.
const TRAFFIC_TOOL_KEYS = new Set(["traffic_search", "traffic_get"]);
// AgentEditor is the tabbed editor for one agent, used inside the agents-page
// drawer (and reused full-page for deep links). Tabs: 配置与提示词 / MCP / Skill /
// Tools. Config + prompt save as before; visibility + tool bindings toggle live.
export function AgentEditor({ agentKey, onSaved }: { agentKey: string; onSaved?: () => void }) {
const [detail, setDetail] = React.useState<AgentDetail | null>(null);
const [versions, setVersions] = React.useState<PromptVersion[]>([]);
const [variables, setVariables] = React.useState<PromptVar[]>([]);
const [mcp, setMcp] = React.useState<MCPServer[]>([]);
const [skills, setSkills] = React.useState<SkillItem[]>([]);
const [tools, setTools] = React.useState<Tool[]>([]);
const [loaded, setLoaded] = React.useState(false);
const [viewVer, setViewVer] = React.useState<PromptVersion | null>(null);
const [diffVer, setDiffVer] = React.useState<PromptVersion | null>(null);
const [prompt, setPrompt] = React.useState("");
const [mcpVisible, setMcpVisible] = React.useState<number[]>([]);
const [skillVisible, setSkillVisible] = React.useState<string[]>([]);
const [preview, setPreview] = React.useState("");
const [maxTurns, setMaxTurns] = React.useState("0");
const [runSecs, setRunSecs] = React.useState("600");
const [webSearch, setWebSearch] = React.useState(false);
const [interactiveShell, setInteractiveShell] = React.useState(false);
const [wrapup, setWrapup] = React.useState("");
const [wrapupDefault, setWrapupDefault] = React.useState("");
const [wrapupTurns, setWrapupTurns] = React.useState("0");
const [wrapupTurnsDefault, setWrapupTurnsDefault] = React.useState(5);
// 任务级超时收尾词(仅 worker/planner)
const [ttSupported, setTtSupported] = React.useState(false);
const [ttWrapup, setTtWrapup] = React.useState("");
const [ttWrapupDefault, setTtWrapupDefault] = React.useState("");
const [ttTurns, setTtTurns] = React.useState("0");
const [ttTurnsDefault, setTtTurnsDefault] = React.useState(5);
const [settings, setSettings] = React.useState<Settings | null>(null);
React.useEffect(() => {
api.mcpServers().then(setMcp).catch(() => {});
api.skills().then(setSkills).catch(() => {});
api.tools().then(setTools).catch(() => {});
api.settings().then(setSettings).catch(() => {});
}, []);
// global gates: traffic tools need 流量捕获, web search needs the master switch.
const captureOn = !!settings?.traffic_capture;
const webSearchGlobalOn = !!settings?.web_search_enabled;
const reload = React.useCallback(() => {
api
.getAgent(agentKey)
.then((d) => {
setDetail(d);
setPrompt(d.prompt ?? "");
setVariables(d.variables ?? []);
setVersions(d.versions ?? []);
setMcpVisible(d.visibility?.mcp ?? []);
setSkillVisible(d.visibility?.skill ?? []);
setMaxTurns(String(d.agent?.max_turns ?? 0));
setRunSecs(String(d.agent?.run_seconds ?? 600));
setWebSearch(!!d.agent?.web_search);
setInteractiveShell(!!d.agent?.interactive_shell);
setWrapup(d.wrapup_prompt ?? "");
setWrapupDefault(d.wrapup_default ?? "");
setWrapupTurns(String(d.wrapup_max_turns ?? 0));
setWrapupTurnsDefault(d.wrapup_max_turns_default ?? 5);
setTtSupported(!!d.task_timeout_wrapup_supported);
setTtWrapup(d.task_timeout_wrapup_prompt ?? "");
setTtWrapupDefault(d.task_timeout_wrapup_default ?? "");
setTtTurns(String(d.task_timeout_wrapup_max_turns ?? 0));
setTtTurnsDefault(d.task_timeout_wrapup_max_turns_default ?? 5);
})
.catch(() => setDetail(null))
.finally(() => setLoaded(true));
}, [agentKey]);
React.useEffect(() => {
reload();
}, [reload]);
async function doPreview() {
try {
const r = await api.previewAgentPrompt(agentKey, prompt);
setPreview(r.error ? "渲染错误:" + r.error : r.rendered);
} catch (e) {
setPreview("预览失败:" + (e as Error).message);
}
}
async function savePrompt() {
try {
const r = await api.saveAgentPrompt(agentKey, prompt);
toast.success(`已保存为版本 v${r.version}`);
reload();
onSaved?.();
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
}
}
async function resetPrompt() {
try {
const r = await api.resetAgentPrompt(agentKey);
toast.success(`已恢复为内置默认(v${r.version})`);
reload();
} catch (e) {
toast.error("恢复失败:" + (e as Error).message);
}
}
async function saveWrapup() {
try {
const turns = Math.max(0, Math.floor(Number(wrapupTurns) || 0));
await api.saveAgentWrapup(agentKey, wrapup, turns);
toast.success(wrapup.trim() || turns > 0 ? "收尾配置已保存(下次运行生效)" : "已清空,将使用内置默认");
reload();
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
}
}
async function resetWrapup() {
try {
await api.resetAgentWrapup(agentKey);
toast.success("已恢复为内置默认");
reload();
} catch (e) {
toast.error("恢复失败:" + (e as Error).message);
}
}
async function saveTaskTimeoutWrapup() {
try {
const turns = Math.max(0, Math.floor(Number(ttTurns) || 0));
await api.saveAgentTaskTimeoutWrapup(agentKey, ttWrapup, turns);
toast.success("任务超时收尾配置已保存(下次运行生效)");
reload();
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
}
}
async function resetTaskTimeoutWrapup() {
try {
await api.resetAgentTaskTimeoutWrapup(agentKey);
toast.success("已恢复为内置默认");
reload();
} catch (e) {
toast.error("恢复失败:" + (e as Error).message);
}
}
async function saveConfig() {
try {
const n = Math.max(0, Math.floor(Number(maxTurns) || 0));
const rs = Math.max(0, Math.floor(Number(runSecs) || 0));
await api.saveAgentConfig(agentKey, { max_turns: n, run_seconds: rs, web_search: webSearch, interactive_shell: interactiveShell });
toast.success("已保存运行配置(立即生效)");
reload();
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
}
}
// applyVis optimistically updates, persists, and toasts success/failure. On
// failure it reverts to the prior selection so the UI never lies about state.
async function applyVis(nextMcp: number[], nextSkill: string[], okMsg: string) {
const prevMcp = mcpVisible;
const prevSkill = skillVisible;
setMcpVisible(nextMcp);
setSkillVisible(nextSkill);
try {
await api.setAgentVisibility(agentKey, nextMcp, nextSkill);
toast.success(okMsg);
onSaved?.(); // refresh the list so the card's MCP/Skill counts stay in sync
} catch (e) {
setMcpVisible(prevMcp);
setSkillVisible(prevSkill);
toast.error("保存失败:" + (e as Error).message);
}
}
function toggleMcp(id: number) {
const on = mcpVisible.includes(id);
const name = mcp.find((m) => m.id === id)?.name ?? String(id);
applyVis(
on ? mcpVisible.filter((x) => x !== id) : [...mcpVisible, id],
skillVisible,
`${on ? "已取消" : "已开启"} MCP「${name}」可见`,
);
}
function toggleSkill(name: string) {
const on = skillVisible.includes(name);
applyVis(
mcpVisible,
on ? skillVisible.filter((x) => x !== name) : [...skillVisible, name],
`${on ? "已取消" : "已开启"} Skill「${name}」可见`,
);
}
async function toggleTool(t: Tool) {
const on = t.agents.includes(agentKey);
const nextAgents = on ? t.agents.filter((k) => k !== agentKey) : [...t.agents, agentKey];
// optimistic update
setTools((ts) => ts.map((x) => (x.key === t.key ? { ...x, agents: nextAgents } : x)));
try {
await api.saveTool(t.key, {
description: t.description,
schema: t.schema,
agents: nextAgents,
enabled: t.enabled,
});
toast.success(`${on ? "已解绑" : "已绑定"}工具「${t.key}」`);
onSaved?.(); // refresh the list so the card's 工具 count stays in sync
} catch (e) {
toast.error("保存工具绑定失败:" + (e as Error).message);
reload();
api.tools().then(setTools).catch(() => {});
}
}
if (loaded && !detail) {
return <div className="text-muted-foreground p-6 text-center text-sm">未找到 Agent:{agentKey}</div>;
}
// config is meaningless for the conversational main agent and the fixed-budget
// goals decomposer; every other agent (workers, custom assistants) honors it.
const showConfig = agentKey !== "mainagent" && agentKey !== "goals";
// web search applies to every conversational/executing agent except the one-shot
// goals decomposer; it's gated by the global master switch.
const showWebSearch = agentKey !== "goals";
// interactive shell (持久 PTY 会话工具族) 同样对除 goals 外的 agent 开放;无全局门控。
const showInteractiveShell = agentKey !== "goals";
// triggers (P3) only attach to custom agents.
const isCustom = !!detail && !detail.agent?.builtin;
return (
<Tabs defaultValue="prompt" className="flex min-h-0 flex-1 flex-col">
<TabsList className="mx-4 mt-2 w-fit">
<TabsTrigger value="prompt">配置与提示词</TabsTrigger>
<TabsTrigger value="wrapup">收尾提示词</TabsTrigger>
<TabsTrigger value="mcp">MCP</TabsTrigger>
<TabsTrigger value="skill">Skill</TabsTrigger>
<TabsTrigger value="tools">Tools</TabsTrigger>
{isCustom && <TabsTrigger value="triggers">触发</TabsTrigger>}
</TabsList>
{/* 配置 + 提示词 */}
<TabsContent value="prompt" className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
<div className="grid gap-4">
{(showConfig || showWebSearch || showInteractiveShell) && (
<div className="grid gap-3 rounded-md border p-3">
<div className="flex flex-wrap items-end gap-3">
{showConfig && (
<>
<div className="grid gap-1.5">
<Label htmlFor="max-turns" className="text-xs">最大循环次数(0=不限)</Label>
<Input id="max-turns" type="number" min={0} className="h-8 w-32"
value={maxTurns} onChange={(e) => setMaxTurns(e.target.value)} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="run-seconds" className="text-xs">运行时长(秒,0=不限)</Label>
<Input id="run-seconds" type="number" min={0} className="h-8 w-32"
value={runSecs} onChange={(e) => setRunSecs(e.target.value)} />
</div>
</>
)}
<Button size="sm" variant="outline" onClick={saveConfig}>
<SaveIcon /> 保存配置
</Button>
</div>
{showWebSearch && (
<div className="flex items-center gap-3 border-t pt-3">
<Switch
id="web-search"
checked={webSearch}
disabled={!webSearchGlobalOn}
onCheckedChange={setWebSearch}
/>
<div className="grid gap-0.5">
<Label htmlFor="web-search" className="text-sm">网络搜索</Label>
<span className="text-muted-foreground text-xs">
{webSearchGlobalOn
? "为该 Agent 开启后(点上方保存生效),可用 web_search 联网检索"
: "需先在「系统配置」开启网络搜索并配置后端,才能在此启用"}
</span>
</div>
</div>
)}
{showInteractiveShell && (
<div className="flex items-center gap-3 border-t pt-3">
<Switch
id="interactive-shell"
checked={interactiveShell}
onCheckedChange={setInteractiveShell}
/>
<div className="grid gap-0.5">
<Label htmlFor="interactive-shell" className="text-sm">交互式 Shell</Label>
<span className="text-muted-foreground text-xs">
为该 Agent 开启后(点上方保存生效),可用持久 PTY 会话工具(shell_open/send/read/close/list)驱动 msfconsole/ssh/REPL 等交互程序
</span>
</div>
</div>
)}
</div>
)}
<div className="grid gap-2">
<Label className="text-muted-foreground text-xs">变量(点击插入占位符,渲染时由运行时数据替换)</Label>
<div className="flex flex-wrap gap-2">
{variables.map((v) => (
<Tooltip key={v.name}>
<TooltipTrigger asChild>
<button type="button" onClick={() => setPrompt((p) => `${p}{{.${v.name}}}`)}
className="hover:bg-muted inline-flex items-center gap-1 rounded-md border bg-muted/40 px-2 py-1 font-mono text-xs">
{`{{.${v.name}}}`}
<Badge variant="secondary" className="px-1 py-0 text-[10px]">{v.source}</Badge>
</button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="font-medium">{v.description}</p>
<p className="text-muted-foreground mt-1">示例:{v.example}</p>
</TooltipContent>
</Tooltip>
))}
{variables.length === 0 && <span className="text-muted-foreground text-xs">(无变量)</span>}
</div>
</div>
<Textarea className="font-mono text-xs" rows={16} value={prompt}
placeholder="留空则使用内置默认提示词" onChange={(e) => setPrompt(e.target.value)} />
<div className="flex flex-wrap gap-2">
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" onClick={doPreview}>
<EyeIcon /> 预览渲染
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>渲染预览</DialogTitle>
<DialogDescription>所有 {`{{.Var}}`} 已由后端用示例值替换。</DialogDescription>
</DialogHeader>
<pre className="bg-muted max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-md p-3 font-mono text-xs">
{preview}
</pre>
</DialogContent>
</Dialog>
<Button size="sm" onClick={savePrompt}>
<SaveIcon /> 保存为新版本
</Button>
<Button variant="outline" size="sm" onClick={resetPrompt}>
<RotateCcwIcon /> 恢复默认
</Button>
</div>
<Separator />
<div className="grid gap-2">
<Label className="text-muted-foreground text-xs">版本历史</Label>
<ul className="grid gap-1">
{versions.map((ver, i) => (
<li key={ver.version} className="flex items-center gap-2 rounded-md px-1 py-0.5 text-xs hover:bg-muted/50">
<span className="font-mono shrink-0">v{ver.version}</span>
{i === 0 && <Badge variant="secondary" className="px-1.5 py-0 shrink-0">当前</Badge>}
<span className="text-muted-foreground truncate flex-1">{ver.note}</span>
{ver.ts && (
<span className="text-muted-foreground/60 shrink-0 tabular-nums">
{new Date(ver.ts).toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })}
</span>
)}
<Button variant="ghost" size="icon-sm" className="size-6 shrink-0" onClick={() => setViewVer(ver)}>
<EyeIcon className="size-3" />
</Button>
{i > 0 && versions[0] && (
<Button variant="ghost" size="icon-sm" className="size-6 shrink-0" onClick={() => setDiffVer(ver)}>
<GitCompareIcon className="size-3" />
</Button>
)}
</li>
))}
{versions.length === 0 && (
<li className="text-muted-foreground text-xs">(暂无保存的版本,使用内置默认)</li>
)}
</ul>
</div>
{/* 查看版本 dialog */}
<Dialog open={!!viewVer} onOpenChange={(o) => { if (!o) setViewVer(null); }}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
v{viewVer?.version}
{viewVer?.version === versions[0]?.version && (
<Badge variant="secondary" className="ml-2 px-1.5 py-0 align-middle">当前</Badge>
)}
</DialogTitle>
<DialogDescription>
{viewVer?.note || "(无备注)"}
{viewVer?.ts && (
<span className="ml-2 text-muted-foreground/60">
{new Date(viewVer.ts).toLocaleString("zh-CN")}
</span>
)}
</DialogDescription>
</DialogHeader>
<pre className="bg-muted max-h-[55vh] overflow-auto whitespace-pre-wrap rounded-md p-3 font-mono text-xs">
{viewVer?.template_text || "(空)"}
</pre>
<div className="flex gap-2 justify-end">
{viewVer && viewVer.version !== versions[0]?.version && (
<Button variant="outline" size="sm" onClick={() => {
if (viewVer) { setDiffVer(viewVer); setViewVer(null); }
}}>
<GitCompareIcon className="mr-1 size-3.5" /> 与当前版本对比
</Button>
)}
<Button size="sm" onClick={() => {
if (viewVer) { setPrompt(viewVer.template_text); setViewVer(null); toast.success(`已加载 v${viewVer.version} 到编辑器,确认后点「保存为新版本」`); }
}}>
加载到编辑器
</Button>
</div>
</DialogContent>
</Dialog>
{/* 版本对比 dialog */}
<Dialog open={!!diffVer} onOpenChange={(o) => { if (!o) setDiffVer(null); }}>
<DialogContent className="sm:max-w-3xl">
<DialogHeader>
<DialogTitle>版本对比:v{diffVer?.version} → v{versions[0]?.version}(当前)</DialogTitle>
<DialogDescription>
<span className="inline-flex items-center gap-3 text-xs">
<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-600 dark:text-red-400">- 删除</span>
<span className="rounded bg-green-500/15 px-1.5 py-0.5 text-green-600 dark:text-green-400">+ 新增</span>
</span>
</DialogDescription>
</DialogHeader>
<DiffView oldText={diffVer?.template_text ?? ""} newText={versions[0]?.template_text ?? ""} />
</DialogContent>
</Dialog>
</div>
</TabsContent>
{/* 收尾提示词 */}
<TabsContent value="wrapup" className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
<div className="grid gap-3">
<p className="text-muted-foreground text-xs leading-relaxed">
当此 Agent 因<b>超时</b>或<b>步数耗尽</b>被终止时,系统会注入这段「收尾提示词」跑一轮收尾:
先把已识别但未落库的内容写回,再输出一句总结(避免烂尾)。留空则使用内置默认。
</p>
<div className="flex items-center gap-2">
<Label className="text-xs">收尾提示词正文</Label>
{wrapup.trim() ? (
<Badge variant="secondary" className="px-1.5 py-0">自定义</Badge>
) : (
<Badge variant="outline" className="px-1.5 py-0">使用内置默认</Badge>
)}
</div>
<Textarea
className="font-mono text-xs"
rows={10}
value={wrapup}
placeholder={wrapupDefault || "留空则使用内置默认收尾提示词"}
onChange={(e) => setWrapup(e.target.value)}
/>
<div className="grid gap-1.5">
<Label htmlFor="wrapup-turns" className="text-xs">
收尾轮数(收尾阶段自身最多跑几轮;0=用内置默认 {wrapupTurnsDefault} 轮)
</Label>
<Input id="wrapup-turns" type="number" min={0} className="h-8 w-32"
value={wrapupTurns} onChange={(e) => setWrapupTurns(e.target.value)} />
</div>
<div className="flex gap-2">
<Button size="sm" onClick={saveWrapup}>保存</Button>
<Button size="sm" variant="outline" onClick={resetWrapup}>恢复默认</Button>
</div>
{wrapupDefault && (
<>
<Separator />
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">内置默认(只读,供参考)</Label>
<pre className="text-muted-foreground max-h-40 overflow-y-auto rounded-md border bg-muted/30 p-2 text-xs whitespace-pre-wrap">
{wrapupDefault}
</pre>
</div>
</>
)}
{ttSupported && (
<>
<Separator className="my-2" />
<p className="text-muted-foreground text-xs leading-relaxed">
<b>任务超时收尾</b>(与上面的 per-run 收尾是<b>两套</b>):当<b>整个任务</b>到达超时上限、即将结束时注入。
与 per-run 语义常相反(如 planner:per-run 说“别停继续规划”,任务超时说“到点停止、做最后判定”)。留空则用内置默认。
</p>
<div className="flex items-center gap-2">
<Label className="text-xs">任务超时收尾提示词正文</Label>
{ttWrapup.trim() ? (
<Badge variant="secondary" className="px-1.5 py-0">自定义</Badge>
) : (
<Badge variant="outline" className="px-1.5 py-0">使用内置默认</Badge>
)}
</div>
<Textarea
className="font-mono text-xs"
rows={10}
value={ttWrapup}
placeholder={ttWrapupDefault || "留空则使用内置默认任务超时收尾提示词"}
onChange={(e) => setTtWrapup(e.target.value)}
/>
<div className="grid gap-1.5">
<Label htmlFor="tt-turns" className="text-xs">
收尾轮数(0=用内置默认 {ttTurnsDefault} 轮)
</Label>
<Input id="tt-turns" type="number" min={0} className="h-8 w-32"
value={ttTurns} onChange={(e) => setTtTurns(e.target.value)} />
</div>
<div className="flex gap-2">
<Button size="sm" onClick={saveTaskTimeoutWrapup}>保存</Button>
<Button size="sm" variant="outline" onClick={resetTaskTimeoutWrapup}>恢复默认</Button>
</div>
{ttWrapupDefault && (
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">内置默认(只读,供参考)</Label>
<pre className="text-muted-foreground max-h-40 overflow-y-auto rounded-md border bg-muted/30 p-2 text-xs whitespace-pre-wrap">
{ttWrapupDefault}
</pre>
</div>
)}
</>
)}
</div>
</TabsContent>
{/* MCP 可见性 */}
<TabsContent value="mcp" className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
<p className="text-muted-foreground mb-3 text-xs">勾选此 Agent 可见的 MCP 服务器。</p>
<div className="grid gap-2">
{mcp.map((m) => (
<label key={m.id} className="flex items-center gap-2 rounded-md border p-2 text-sm">
<Checkbox checked={mcpVisible.includes(m.id)} onCheckedChange={() => toggleMcp(m.id)} />
{m.name}
<span className="text-muted-foreground ml-auto text-xs">{m.transport}</span>
</label>
))}
{mcp.length === 0 && <span className="text-muted-foreground text-xs">(暂无 MCP)</span>}
</div>
</TabsContent>
{/* Skill 可见性 */}
<TabsContent value="skill" className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
<p className="text-muted-foreground mb-3 text-xs">勾选此 Agent 可见的 Skill。</p>
<div className="grid gap-2">
{skills.map((s) => (
<label key={s.name} className="flex items-center gap-2 rounded-md border p-2 text-sm">
<Checkbox checked={skillVisible.includes(s.name)} onCheckedChange={() => toggleSkill(s.name)} />
<span className="font-mono text-xs">{s.name}</span>
{s.description && <span className="text-muted-foreground ml-auto truncate text-xs">{s.description}</span>}
</label>
))}
{skills.length === 0 && <span className="text-muted-foreground text-xs">(暂无 Skill)</span>}
</div>
</TabsContent>
{/* Tools 绑定 */}
<TabsContent value="tools" className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
<p className="text-muted-foreground mb-3 text-xs">勾选绑定给此 Agent 的内置工具。</p>
<div className="grid gap-2">
{tools.map((t) => {
const isTraffic = TRAFFIC_TOOL_KEYS.has(t.key);
const gated = isTraffic && !captureOn; // traffic tools need 流量捕获 on
return (
<label
key={t.key}
className={cn(
"flex items-center gap-2 rounded-md border p-2 text-sm",
gated && "opacity-60",
)}
>
<Checkbox
checked={t.agents.includes(agentKey)}
disabled={gated}
onCheckedChange={() => toggleTool(t)}
/>
<span className="font-mono text-xs">{t.key}</span>
{isTraffic && (
<Badge variant="secondary" className="px-1 py-0 text-[9px]">流量</Badge>
)}
{!t.enabled && (
<Badge variant="outline" className="text-destructive px-1 py-0 text-[9px]">已停用</Badge>
)}
{gated ? (
<span className="text-muted-foreground ml-auto text-xs">需开启流量捕获</span>
) : (
t.description && (
<span className="text-muted-foreground ml-auto line-clamp-1 max-w-[55%] text-xs">
{t.description}
</span>
)
)}
</label>
);
})}
{tools.length === 0 && <span className="text-muted-foreground text-xs">(暂无工具)</span>}
</div>
</TabsContent>
{/* 触发(P3, 仅自定义 agent) */}
{isCustom && (
<TabsContent value="triggers" className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">
<AgentTriggersTab agentKey={agentKey} />
</TabsContent>
)}
</Tabs>
);
}
// ---------- Diff helpers ----------
type DiffLine = { type: "same" | "add" | "del"; text: string };
function computeDiff(oldText: string, newText: string): DiffLine[] {
const a = oldText.split("\n");
const b = newText.split("\n");
const m = a.length;
const n = b.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 1; i <= m; i++)
for (let j = 1; j <= n; j++)
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
const result: DiffLine[] = [];
let i = m;
let j = n;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) {
result.unshift({ type: "same", text: a[i - 1] });
i--;
j--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
result.unshift({ type: "add", text: b[j - 1] });
j--;
} else {
result.unshift({ type: "del", text: a[i - 1] });
i--;
}
}
return result;
}
function DiffView({ oldText, newText }: { oldText: string; newText: string }) {
const lines = React.useMemo(() => computeDiff(oldText, newText), [oldText, newText]);
return (
<pre className="max-h-[60vh] overflow-auto rounded-md border bg-muted/30 p-2 font-mono text-xs leading-5">
{lines.map((l, idx) => (
<div
key={idx}
className={cn(
"whitespace-pre-wrap px-1",
l.type === "del" && "bg-red-500/15 text-red-700 dark:text-red-400",
l.type === "add" && "bg-green-500/15 text-green-700 dark:text-green-400",
l.type === "same" && "text-muted-foreground",
)}
>
<span className="select-none mr-1 opacity-50">{l.type === "del" ? "-" : l.type === "add" ? "+" : " "}</span>
{l.text}
</div>
))}
</pre>
);
}
// AgentTriggersTab manages a custom agent's P3 triggers: list + add + delete.
// Each trigger fires (定时/发现finding/目标达成,可多选) → a new conversation runs
// in parallel with the base user message + auto context appended by the backend.
function AgentTriggersTab({ agentKey }: { agentKey: string }) {
const [triggers, setTriggers] = React.useState<AgentTrigger[]>([]);
const [intervalSec, setIntervalSec] = React.useState("0");
const [onFinding, setOnFinding] = React.useState(false);
const [onGoalMet, setOnGoalMet] = React.useState(false);
const [onTaskTimeout, setOnTaskTimeout] = React.useState(false);
const [intervalMsg, setIntervalMsg] = React.useState("");
const [findingMsg, setFindingMsg] = React.useState("");
const [goalMsg, setGoalMsg] = React.useState("");
const [taskTimeoutMsg, setTaskTimeoutMsg] = React.useState("");
const [saving, setSaving] = React.useState(false);
const reload = React.useCallback(() => {
api.agentTriggers(agentKey).then(setTriggers).catch(() => setTriggers([]));
}, [agentKey]);
React.useEffect(() => {
reload();
}, [reload]);
async function create() {
const n = Math.max(0, Math.floor(Number(intervalSec) || 0));
if (n === 0 && !onFinding && !onGoalMet && !onTaskTimeout) {
toast.error("至少选择一种触发条件");
return;
}
setSaving(true);
try {
await api.createTrigger(agentKey, {
enabled: true,
interval_sec: n,
on_finding: onFinding,
on_goal_met: onGoalMet,
on_task_timeout: onTaskTimeout,
interval_message: intervalMsg.trim(),
finding_message: findingMsg.trim(),
goal_message: goalMsg.trim(),
task_timeout_message: taskTimeoutMsg.trim(),
});
toast.success("已添加触发器");
setIntervalSec("0");
setOnFinding(false);
setOnGoalMet(false);
setOnTaskTimeout(false);
setIntervalMsg("");
setFindingMsg("");
setGoalMsg("");
setTaskTimeoutMsg("");
reload();
} catch (e) {
toast.error("添加失败:" + (e as Error).message);
} finally {
setSaving(false);
}
}
async function toggleEnabled(t: AgentTrigger) {
try {
await api.updateTrigger(t.id, {
enabled: !t.enabled,
interval_sec: t.interval_sec,
on_finding: t.on_finding,
on_goal_met: t.on_goal_met,
on_task_timeout: t.on_task_timeout,
interval_message: t.interval_message,
finding_message: t.finding_message,
goal_message: t.goal_message,
task_timeout_message: t.task_timeout_message,
});
reload();
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
}
}
async function del(id: number) {
try {
await api.deleteTrigger(id);
reload();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}
function condLabel(t: AgentTrigger): string {
const parts: string[] = [];
if (t.interval_sec > 0) parts.push(`每 ${t.interval_sec}s`);
if (t.on_finding) parts.push("发现 finding");
if (t.on_goal_met) parts.push("目标达成");
if (t.on_task_timeout) parts.push("任务超时");
return parts.join(" · ") || "(无条件)";
}
return (
<div className="grid gap-4">
<p className="text-muted-foreground text-xs">
触发器让这个自定义 Agent 自动运行:每次触发都<b>新建一个会话并行运行</b>(在「对话」页可见)。
可多选触发条件;系统会把「本次为何触发 + 相关任务/finding/目标」自动附加到你写的基础消息后面。
</p>
{/* 新增触发器 */}
<div className="grid gap-3 rounded-md border p-3">
<Label className="text-muted-foreground text-xs">新增触发器(每种条件可填各自的用户消息)</Label>
{/* 定时 */}
<div className="grid gap-1.5">
<div className="flex items-center gap-2">
<Label htmlFor="tr-interval" className="text-xs">定时触发:每</Label>
<Input id="tr-interval" type="number" min={0} className="h-8 w-24"
value={intervalSec} onChange={(e) => setIntervalSec(e.target.value)} />
<span className="text-muted-foreground text-xs">秒(0=关)</span>
</div>
{Number(intervalSec) > 0 && (
<Textarea className="text-xs" rows={2} value={intervalMsg}
placeholder="定时触发时发给 agent 的话,如:巡检所有任务" onChange={(e) => setIntervalMsg(e.target.value)} />
)}
</div>
{/* finding */}
<div className="grid gap-1.5">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={onFinding} onCheckedChange={(v) => setOnFinding(!!v)} /> 发现 finding 时触发
</label>
{onFinding && (
<Textarea className="text-xs" rows={2} value={findingMsg}
placeholder="发现 finding 时发给 agent 的话(系统会附带任务与 finding 详情)" onChange={(e) => setFindingMsg(e.target.value)} />
)}
</div>
{/* 目标达成 */}
<div className="grid gap-1.5">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={onGoalMet} onCheckedChange={(v) => setOnGoalMet(!!v)} /> 目标达成时触发
</label>
{onGoalMet && (
<Textarea className="text-xs" rows={2} value={goalMsg}
placeholder="目标达成时发给 agent 的话(系统会附带任务与达成的目标)" onChange={(e) => setGoalMsg(e.target.value)} />
)}
</div>
{/* 任务超时 */}
<div className="grid gap-1.5">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={onTaskTimeout} onCheckedChange={(v) => setOnTaskTimeout(!!v)} /> 任务超时时触发
</label>
{onTaskTimeout && (
<Textarea className="text-xs" rows={2} value={taskTimeoutMsg}
placeholder="任务超时时发给 agent 的话(系统会附带任务编号与目标)" onChange={(e) => setTaskTimeoutMsg(e.target.value)} />
)}
</div>
<div>
<Button size="sm" onClick={create} disabled={saving}>
<SaveIcon /> 添加触发器
</Button>
</div>
</div>
{/* 已有触发器 */}
<div className="grid gap-2">
<Label className="text-muted-foreground text-xs">已有触发器</Label>
{triggers.length === 0 && <span className="text-muted-foreground text-xs">(暂无)</span>}
{triggers.map((t) => (
<div key={t.id} className="flex items-start gap-2 rounded-md border p-2 text-sm">
<Switch checked={t.enabled} onCheckedChange={() => toggleEnabled(t)} className="mt-0.5" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-medium">{condLabel(t)}</span>
{!t.enabled && (
<Badge variant="outline" className="text-destructive px-1 py-0 text-[9px]">已停用</Badge>
)}
</div>
<div className="text-muted-foreground grid gap-0.5 text-xs">
{t.interval_sec > 0 && t.interval_message && <div className="line-clamp-1">定时:{t.interval_message}</div>}
{t.on_finding && t.finding_message && <div className="line-clamp-1">finding:{t.finding_message}</div>}
{t.on_goal_met && t.goal_message && <div className="line-clamp-1">目标:{t.goal_message}</div>}
{t.on_task_timeout && t.task_timeout_message && <div className="line-clamp-1">超时:{t.task_timeout_message}</div>}
</div>
</div>
<Button variant="ghost" size="icon-sm" className="text-muted-foreground hover:text-destructive"
onClick={() => del(t.id)}>
<Trash2Icon className="size-3.5" />
</Button>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,497 @@
import FullCalendar, { type CalendarOptions, type DayCellInfo } from '@fullcalendar/react'
import '@fullcalendar/react/skeleton.css'
import { cn } from '@/lib/utils'
const xxsTextClass = 'text-[0.6875rem]/[1.090909]'
const blockPointerResizerClass = 'absolute hidden group-hover:block'
const rowPointerResizerClass = `${blockPointerResizerClass} inset-y-0 w-2`
const columnPointerResizerClass = `${blockPointerResizerClass} inset-x-0 h-2`
const blockTouchResizerClass = 'absolute size-2 border border-(--fc-event-color) bg-background rounded-full'
const rowTouchResizerClass = `${blockTouchResizerClass} top-1/2 -mt-1`
const columnTouchResizerClass = `${blockTouchResizerClass} left-1/2 -ml-1`
const tallDayCellBottomClass = 'min-h-3'
const getShortDayCellBottomClass = (info: DayCellInfo) => cn(
!info.isNarrow && 'min-h-0.5'
)
const dayRowCommonClasses: CalendarOptions = {
/* Day Row > List-Item Event
----------------------------------------------------------------------------------------------- */
listItemEventClass: (info) => cn(
'mb-px p-px rounded-sm',
info.isNarrow ? 'mx-0.5' : 'mx-1',
info.isSelected
? 'bg-foreground/5'
: 'hover:bg-foreground/5',
),
listItemEventInnerClass: (info) => cn(
'flex flex-row items-center justify-between',
info.isNarrow
? `py-px ${xxsTextClass}`
: 'py-0.5 text-xs',
),
listItemEventTimeClass: (info) => cn(
info.isNarrow ? 'px-px' : 'px-0.5',
'order-1 whitespace-nowrap overflow-hidden shrink-1',
),
listItemEventTitleClass: (info) => cn(
info.isNarrow ? 'px-px' : 'px-0.5',
'font-medium whitespace-nowrap overflow-hidden shrink-100',
info.timeText && 'text-ellipsis',
),
/* Day Row > Row Event
----------------------------------------------------------------------------------------------- */
rowEventClass: (info) => cn(
info.isStart && (info.isNarrow ? 'ms-0.5' : 'ms-1'),
info.isEnd && (info.isNarrow ? 'me-0.5' : 'me-1'),
),
rowEventInnerClass: (info) => cn(info.isNarrow ? 'py-px' : 'py-0.5'),
/* Day Row > More-Link
----------------------------------------------------------------------------------------------- */
rowMoreLinkClass: (info) => cn(
'mb-px border rounded-sm',
info.isNarrow
? 'mx-0.5 border-primary hover:bg-foreground/5'
: 'self-start mx-1 border-transparent bg-foreground/5 hover:bg-foreground/10',
),
rowMoreLinkInnerClass: (info) => cn(
info.isNarrow
? `p-px ${xxsTextClass}`
: 'p-0.5 text-xs',
),
}
export type EventCalendarViewProps =
CalendarOptions &
Required<Pick<CalendarOptions, 'popoverCloseContent'>> // ensure callers define icons
export function EventCalendarViews({
height,
views: userViews,
...restOptions
}: EventCalendarViewProps) {
return (
<FullCalendar
height={height}
/* Abstract Event
----------------------------------------------------------------------------------------- */
eventShortHeight={50}
eventColor='var(--primary)'
eventContrastColor='var(--primary-foreground)'
eventClass={(info) => cn(
info.isSelected
? ['outline-3', info.isDragging && 'shadow-lg']
: 'focus-visible:outline-3',
'outline-ring/50',
)}
/* Background Event
----------------------------------------------------------------------------------------- */
backgroundEventColor='var(--chart-3)'
backgroundEventClass='not-print:bg-[color-mix(in_oklab,var(--fc-event-color)_10%,transparent)] print:border-1 print:border-(--fc-event-color)'
backgroundEventTitleClass={(info) => cn(
'opacity-50 italic',
(info.isNarrow || info.isShort)
? `p-1 ${xxsTextClass}`
: 'p-2 text-xs',
)}
/* List-Item Event
----------------------------------------------------------------------------------------- */
listItemEventTimeClass='text-muted-foreground'
/* Block Event
----------------------------------------------------------------------------------------- */
blockEventClass={(info) => cn(
'group relative border-transparent print:border-(--fc-event-color) bg-(--fc-event-color) print:bg-white hover:bg-[color-mix(in_oklab,var(--fc-event-color)_92%,var(--fc-event-contrast-color))]',
info.isInteractive && 'active:bg-[color-mix(in_oklab,var(--fc-event-color)_85%,var(--fc-event-contrast-color))]',
(info.isDragging && !info.isSelected) && 'opacity-75',
)}
blockEventInnerClass='text-(--fc-event-contrast-color) print:text-black'
blockEventTimeClass='whitespace-nowrap overflow-hidden shrink-1'
blockEventTitleClass='whitespace-nowrap overflow-hidden shrink-100'
/* Row Event
----------------------------------------------------------------------------------------- */
rowEventClass={(info) => cn(
'mb-px border-y',
info.isStart && 'rounded-s-sm border-s',
info.isEnd && 'rounded-e-sm border-e',
)}
rowEventBeforeClass={(info) => cn(
info.isStartResizable && [
info.isSelected ? rowTouchResizerClass : rowPointerResizerClass,
'-start-1',
]
)}
rowEventAfterClass={(info) => cn(
info.isEndResizable && [
info.isSelected ? rowTouchResizerClass : rowPointerResizerClass,
'-end-1',
]
)}
rowEventInnerClass={(info) => cn(
'flex flex-row items-center',
info.isNarrow ? xxsTextClass : 'text-xs',
)}
rowEventTimeClass={(info) => cn(
info.isNarrow ? 'ps-0.5' : 'ps-1'
)}
rowEventTitleClass={(info) => cn(
info.isNarrow ? 'px-0.5' : 'px-1',
'font-medium',
)}
/* Column Event
----------------------------------------------------------------------------------------- */
columnEventClass={(info) => cn(
'border-x ring ring-background',
info.isStart && ['border-t rounded-t-lg', info.isNarrow ? 'mt-px' : 'mt-0.5'],
info.isEnd && ['border-b rounded-b-lg', info.isNarrow ? 'mb-px' : 'mb-0.5'],
)}
columnEventBeforeClass={(info) => cn(
info.isStartResizable && [
info.isSelected ? columnTouchResizerClass : columnPointerResizerClass,
'-top-1',
]
)}
columnEventAfterClass={(info) => cn(
info.isEndResizable && [
info.isSelected ? columnTouchResizerClass : columnPointerResizerClass,
'-bottom-1',
]
)}
columnEventInnerClass={(info) => cn(
'flex',
info.isShort
? 'flex-row items-center gap-1 p-1'
: ['flex-col', info.isNarrow ? 'px-1 py-0.5' : 'px-2 py-1'],
(info.isNarrow || info.isShort) ? xxsTextClass : 'text-xs',
)}
columnEventTimeClass={(info) => cn(
!info.isShort && (info.isNarrow ? 'pt-0.5' : 'pt-1')
)}
columnEventTitleClass={(info) => cn(
!info.isShort && (info.isNarrow ? 'py-0.5' : 'py-1'),
'font-medium',
)}
/* More-Link
----------------------------------------------------------------------------------------- */
moreLinkClass="focus-visible:outline-3 outline-ring/50"
moreLinkInnerClass='whitespace-nowrap overflow-hidden'
columnMoreLinkClass="my-0.5 border border-transparent print:border-black rounded-md bg-[color-mix(in_oklab,var(--foreground)_10%,var(--background))] hover:bg-[color-mix(in_oklab,var(--foreground)_13%,var(--background))] print:bg-white ring ring-background"
columnMoreLinkInnerClass={(info) => cn(
info.isNarrow
? `p-0.5 ${xxsTextClass}`
: 'p-1 text-xs',
)}
/* Day Header
----------------------------------------------------------------------------------------- */
dayHeaderClass={(info) => cn(
'justify-center',
info.inPopover ? 'border-b bg-foreground/5' :
info.isMajor && 'border border-foreground/20',
)}
dayHeaderInnerClass={(info) => cn(
'flex flex-row items-center',
info.isNarrow ? 'text-xs' : 'text-sm',
info.inPopover ? [
'm-1.5 px-1 py-0.5 rounded-sm font-semibold',
info.hasNavLink && 'hover:bg-foreground/5',
] : !info.dayNumberText ? [
'mx-0.5 my-1.5 py-0.5 px-1.5 rounded-sm text-muted-foreground',
info.hasNavLink && 'hover:bg-foreground/5',
] : !info.isToday ? [
'mx-2 my-2.5 h-6 px-1.5 rounded-sm text-muted-foreground',
info.hasNavLink && 'hover:bg-foreground/5',
] : (
'group mx-2 my-2 h-7 outline-none'
)
)}
dayHeaderContent={(info) => (
(info.inPopover || !info.dayNumberText || !info.isToday) ? (
<>{info.text}</>
) : (
<>
{info.textParts.map((textPart, i) => (
<span
key={i}
className={cn(
'whitespace-pre',
(textPart.type === 'day' && info.isToday)
? [
'first:-ms-1 last:-me-1 size-7 rounded-full font-semibold flex flex-row items-center justify-center bg-chart-1/50',
info.hasNavLink && 'group-hover:bg-chart-1/60 group-focus-visible:outline-3 outline-ring/50',
]
: 'text-muted-foreground',
)}
>{textPart.value}</span>
))}
</>
)
)}
/* Day Cell
----------------------------------------------------------------------------------------- */
dayCellClass={(info) => cn(
'border',
info.isMajor && 'border-foreground/20',
)}
dayCellTopClass={(info) => cn(
info.isNarrow ? 'min-h-0.5' : 'min-h-1',
'flex flex-row justify-end',
)}
dayCellTopInnerClass={(info) => cn(
'flex flex-row items-center',
info.isNarrow
? `my-px h-5 ${xxsTextClass}`
: 'my-1 h-6 text-sm',
!info.isToday
? [
'rounded-s-sm whitespace-nowrap',
!info.isOther && 'font-semibold',
info.isNarrow ? 'px-1' : 'px-2',
!info.monthText && 'text-muted-foreground',
info.hasNavLink && 'hover:bg-foreground/5',
]
: [
'group outline-none',
info.isNarrow
? 'mx-px'
: 'mx-2',
]
)}
dayCellTopContent={(info) => (
!info.isToday ? (
<>{info.text}</>
) : (
<>
{info.textParts.map((textPart, i) => (
<span
key={i}
className={cn(
'whitespace-pre',
(textPart.type === 'day' && info.isToday)
? [
'rounded-full font-semibold flex flex-row items-center justify-center bg-chart-1/50',
info.hasNavLink && 'group-hover:bg-chart-1/60 group-focus-visible:outline-3 outline-ring/50',
info.isNarrow
? 'size-5'
: 'size-6 first:-ms-1 last:-me-1',
]
: (!info.monthText && 'text-muted-foreground'),
)}
>{textPart.value}</span>
))}
</>
)
)}
dayCellInnerClass={(info) => cn(info.inPopover && 'p-2')}
/* Popover
----------------------------------------------------------------------------------------- */
popoverClass='border rounded-md overflow-hidden shadow-lg m-1 bg-popover text-popover-foreground min-w-55'
popoverCloseClass="group absolute top-1.5 end-1.5 p-0.5 rounded-sm hover:bg-foreground/5 focus-visible:outline-3 outline-ring/50"
/* Lane
----------------------------------------------------------------------------------------- */
dayLaneClass={(info) => cn(
'border',
info.isMajor && 'border-foreground/20',
info.isDisabled && 'bg-foreground/3',
)}
dayLaneInnerClass={(info) => (
info.isStack
? 'm-1'
: info.isNarrow ? 'mx-px' : 'mx-0.5'
)}
slotLaneClass={(info) => cn(
'border',
info.isMinor && 'border-dotted',
)}
/* List Day
----------------------------------------------------------------------------------------- */
listDayClass={(info) => cn(
'flex flex-col',
!info.isLast && 'border-b',
)}
listDayHeaderClass="-mb-px border-b bg-[color-mix(in_oklab,var(--foreground)_3%,var(--background))] flex flex-row items-center justify-between"
listDayHeaderInnerClass={(info) => cn(
'm-1.5 px-1.5 py-0.5 rounded-sm text-sm',
!info.level && 'font-semibold',
(!info.level && info.isToday)
? [
'bg-chart-1/50',
info.hasNavLink && 'hover:bg-chart-1/60 focus-visible:outline-3 outline-ring/50',
]
: info.hasNavLink && 'hover:bg-foreground/5',
)}
listDayBodyClass='mt-px px-1.5 py-2 gap-2'
/* Single Month (in Multi-Month)
----------------------------------------------------------------------------------------- */
singleMonthClass={(info) => cn(
info.multiMonthColumns > 1 && 'm-3',
(info.multiMonthColumns === 1 && !info.isLast) && 'border-b',
)}
singleMonthHeaderClass={(info) => cn(
info.multiMonthColumns > 1 ? 'pb-2' : 'py-1 border-b bg-background',
'items-center',
)}
singleMonthHeaderInnerClass={(info) => cn(
'px-1.5 py-0.5 rounded-sm text-base font-semibold',
info.hasNavLink && 'hover:bg-foreground/5',
)}
/* Misc Table
----------------------------------------------------------------------------------------- */
tableBodyClass='bg-background'
fillerClass='border opacity-50'
dayNarrowWidth={100}
dayHeaderRowClass='border'
dayRowClass='border'
slotHeaderRowClass='border'
slotHeaderInnerClass='text-muted-foreground'
/* Misc Content
----------------------------------------------------------------------------------------- */
navLinkClass="focus-visible:outline-3 outline-ring/50"
inlineWeekNumberClass={(info) => cn(
'absolute start-0 whitespace-nowrap rounded-e-sm text-muted-foreground',
info.isNarrow
? `top-0.5 my-px p-0.5 ${xxsTextClass}`
: 'top-1 p-1 text-xs',
info.hasNavLink && 'hover:bg-foreground/5',
)}
highlightClass='bg-chart-1/20'
nonBusinessHoursClass='bg-foreground/3'
nowIndicatorLineClass='-m-px border-1 border-destructive'
nowIndicatorDotClass="-m-[6px] border-6 border-destructive size-0 rounded-full ring-2 ring-background"
/* View-Specific Options
----------------------------------------------------------------------------------------- */
views={{
...userViews,
dayGrid: {
...dayRowCommonClasses,
tableHeaderClass: 'bg-background',
dayHeaderAlign: (info) => info.inPopover ? 'start' : info.isNarrow ? 'center' : 'end',
dayHeaderDividerClass: 'border-b',
dayCellBottomClass: getShortDayCellBottomClass,
...userViews?.dayGrid,
},
multiMonth: {
...dayRowCommonClasses,
className: 'bg-foreground/3',
tableHeaderClass: (info) => cn(info.multiMonthColumns === 1 && 'bg-background'),
dayHeaderAlign: (info) => info.inPopover ? 'start' : info.isNarrow ? 'center' : 'end',
dayHeaderDividerClass: (info) => cn(info.multiMonthColumns === 1 && 'border-b'),
dayCellBottomClass: getShortDayCellBottomClass,
tableBodyClass: (info) => cn(info.multiMonthColumns > 1 && 'border rounded-sm overflow-hidden'),
...userViews?.multiMonth,
},
timeGrid: {
...dayRowCommonClasses,
tableHeaderClass: 'bg-background',
dayHeaderAlign: (info) => info.inPopover ? 'start' : 'center',
dayHeaderDividerClass: (info) => cn(
'border-b',
!info.options.allDaySlot && 'border-foreground/20 not-print:shadow-sm',
),
dayCellBottomClass: tallDayCellBottomClass,
/* TimeGrid > Week Number Header
------------------------------------------------------------------------------------- */
weekNumberHeaderClass: 'items-center justify-end',
weekNumberHeaderInnerClass: (info) => cn(
'mx-0.5 h-6 px-1.5 text-muted-foreground flex flex-row items-center rounded-sm',
info.isNarrow ? 'text-xs' : 'text-sm',
info.hasNavLink && 'hover:bg-foreground/5',
),
/* TimeGrid > All-Day Header
------------------------------------------------------------------------------------- */
allDayHeaderClass: 'items-center',
allDayHeaderInnerClass: (info) => cn(
'm-2 text-muted-foreground',
info.isNarrow ? xxsTextClass : 'text-xs',
),
allDayDividerClass: 'border-b border-foreground/20 not-print:shadow-sm',
/* TimeGrid > Slot Header
------------------------------------------------------------------------------------- */
slotHeaderClass: 'justify-end',
slotHeaderInnerClass: (info) => cn(
'relative m-2',
info.isNarrow
? `-top-3.5 ${xxsTextClass}`
: '-top-4 text-xs',
info.isFirst && 'hidden',
),
slotHeaderDividerClass: 'border-e',
...userViews?.timeGrid,
},
list: {
/* List-View > List-Item Event
------------------------------------------------------------------------------------- */
listItemEventClass: (info) => cn(
'group py-1 rounded-sm hover:bg-muted/50',
info.isInteractive && 'focus-visible:bg-muted/50 -outline-offset-3',
),
listItemEventBeforeClass: 'w-1.5 bg-(--fc-event-color) rounded-full',
listItemEventInnerClass: '[display:contents]',
listItemEventTimeClass: "-order-1 shrink-0 w-1/2 max-w-60 px-4 py-2 whitespace-nowrap overflow-hidden text-ellipsis text-sm",
listItemEventTitleClass: (info) => cn(
'grow min-w-0 px-4 py-2 whitespace-nowrap overflow-hidden text-sm',
info.event.url && 'group-hover:underline',
),
/* No-Events Screen
------------------------------------------------------------------------------------- */
noEventsClass: 'grow flex flex-col items-center justify-center',
noEventsInnerClass: 'py-15 text-muted-foreground',
...userViews?.list,
},
}}
{...restOptions}
/>
)
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import * as React from "react";
import { format, subDays } from "date-fns";
import type { DateRange } from "react-day-picker";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
interface DateRangePickerProps {
value?: DateRange;
onChange?: (value: DateRange | undefined) => void;
}
export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
const [open, setOpen] = React.useState(false);
const [internalDateRange, setInternalDateRange] = React.useState<DateRange | undefined>(() => {
const to = new Date();
const from = subDays(to, 29);
return { from, to };
});
const dateRange = value ?? internalDateRange;
let dateRangeLabel = "Select date";
if (dateRange?.from) {
dateRangeLabel = format(dateRange.from, "d MMM yyyy");
}
if (dateRange?.from && dateRange.to) {
dateRangeLabel = `${format(dateRange.from, "d MMM yyyy")} - ${format(dateRange.to, "d MMM yyyy")}`;
}
const handleDateChange = (nextValue: DateRange | undefined) => {
if (!value) {
setInternalDateRange(nextValue);
}
onChange?.(nextValue);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" id="date" className="font-normal">
{dateRangeLabel}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="end">
<Calendar
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={handleDateChange}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
);
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import * as React from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
// Tailwind-styled element overrides (no typography plugin in this project, so we
// style each element). `node` is stripped — it's not a valid DOM attribute.
const components: Components = {
h1: ({ node, ...p }) => <div className="mt-2.5 text-base font-semibold" {...p} />,
h2: ({ node, ...p }) => <div className="mt-2 text-sm font-semibold" {...p} />,
h3: ({ node, ...p }) => <div className="mt-1.5 text-sm font-medium text-foreground/90" {...p} />,
h4: ({ node, ...p }) => <div className="mt-1.5 text-sm font-medium text-foreground/90" {...p} />,
p: ({ node, ...p }) => <p className="leading-relaxed" {...p} />,
ul: ({ node, ...p }) => <ul className="my-1 list-disc space-y-0.5 pl-5" {...p} />,
ol: ({ node, ...p }) => <ol className="my-1 list-decimal space-y-0.5 pl-5" {...p} />,
strong: ({ node, ...p }) => <strong className="font-semibold" {...p} />,
a: ({ node, ...p }) => (
<a className="text-primary underline underline-offset-2" target="_blank" rel="noreferrer" {...p} />
),
hr: () => <hr className="my-2 border-border" />,
blockquote: ({ node, ...p }) => (
<blockquote className="my-1 border-l-2 border-border pl-2.5 text-muted-foreground" {...p} />
),
pre: ({ node, ...p }) => (
<pre className="my-1.5 overflow-auto rounded-md bg-muted/70 p-2.5 text-xs leading-relaxed" {...p} />
),
code: ({ node, className, children, ...rest }) => {
const block = /language-/.test(className || "");
return block ? (
<code className={"font-mono text-xs " + (className || "")} {...rest}>
{children}
</code>
) : (
<code className="break-all rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]" {...rest}>
{children}
</code>
);
},
table: ({ node, ...p }) => (
<div className="my-1.5 overflow-x-auto">
<table className="w-full border-collapse text-xs" {...p} />
</div>
),
th: ({ node, ...p }) => (
<th className="border border-border bg-muted/60 px-2 py-1 text-left font-medium" {...p} />
),
td: ({ node, ...p }) => <td className="border border-border px-2 py-1 align-top" {...p} />,
};
export function Markdown({ text }: { text: string }) {
return (
<div className="min-w-0 space-y-1.5 text-sm [&>*:first-child]:mt-0">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{text}
</ReactMarkdown>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import type * as React from "react";
import type { SimpleIcon as SimpleIconType } from "simple-icons";
import { cn } from "@/lib/utils";
type SimpleIconProps = {
icon: SimpleIconType;
className?: string;
} & React.SVGProps<SVGSVGElement>;
export function SimpleIcon({ icon, className, ...props }: SimpleIconProps) {
const { title, path } = icon;
return (
<svg
viewBox="0 0 24 24"
aria-label={title}
aria-hidden="false"
focusable="false"
className={cn("size-5 fill-foreground", className)}
{...props}
>
<title>{title}</title>
<path d={path} />
</svg>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { cn } from "@/lib/utils";
import {
statusMeta,
toneClasses,
toneDot,
type StatusDomain,
type Tone,
} from "@/lib/status";
export function StatusBadge({
domain,
value,
dot = false,
className,
}: {
domain: StatusDomain;
value: string;
dot?: boolean;
className?: string;
}) {
const meta = statusMeta(domain, value);
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium whitespace-nowrap",
toneClasses[meta.tone],
className,
)}
>
{dot && (
<span className={cn("size-1.5 rounded-full", toneDot[meta.tone])} />
)}
{meta.label}
</span>
);
}
export function ToneDot({ tone }: { tone: Tone }) {
return <span className={cn("size-2 rounded-full", toneDot[tone])} />;
}
+134
View File
@@ -0,0 +1,134 @@
"use client";
import * as React from "react";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
} from "@/components/ui/pagination";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface TablePaginationProps {
page: number; // 1-based
pageSize: number;
total: number; // total (after filtering)
onPageChange: (p: number) => void;
onPageSizeChange: (s: number) => void;
pageSizeOptions?: number[];
}
// pageWindows returns the sequence of page numbers / ellipsis to render.
function pageWindows(page: number, total: number): (number | "...")[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const out: (number | "...")[] = [1];
if (page > 3) out.push("...");
const lo = Math.max(2, page - 1);
const hi = Math.min(total - 1, page + 1);
for (let i = lo; i <= hi; i++) out.push(i);
if (page < total - 2) out.push("...");
out.push(total);
return out;
}
export function TablePagination({
page,
pageSize,
total,
onPageChange,
onPageSizeChange,
pageSizeOptions = [10, 20, 50],
}: TablePaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const from = total === 0 ? 0 : (page - 1) * pageSize + 1;
const to = Math.min(page * pageSize, total);
return (
<div className="flex flex-wrap items-center justify-between gap-2 border-t px-4 py-2 lg:px-6">
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<Select
value={String(pageSize)}
onValueChange={(v) => {
onPageSizeChange(Number(v));
onPageChange(1);
}}
>
<SelectTrigger size="sm" className="h-7 w-16">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((s) => (
<SelectItem key={s} value={String(s)}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
<span>条/页</span>
{total > 0 ? (
<span className="tabular-nums">
{from}–{to} / 共 {total} 条
</span>
) : (
<span>共 0 条</span>
)}
</div>
{totalPages > 1 && (
<Pagination className="mx-0 w-auto justify-end">
<PaginationContent>
<PaginationItem>
<Button
variant="ghost"
size="icon-sm"
disabled={page === 1}
onClick={() => onPageChange(page - 1)}
aria-label="上一页"
>
<ChevronLeftIcon className="size-4" />
</Button>
</PaginationItem>
{pageWindows(page, totalPages).map((p, i) =>
p === "..." ? (
<PaginationItem key={`el-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={p}>
<Button
variant={p === page ? "outline" : "ghost"}
size="icon-sm"
onClick={() => onPageChange(p)}
aria-current={p === page ? "page" : undefined}
>
{p}
</Button>
</PaginationItem>
),
)}
<PaginationItem>
<Button
variant="ghost"
size="icon-sm"
disabled={page === totalPages}
onClick={() => onPageChange(page + 1)}
aria-label="下一页"
>
<ChevronRightIcon className="size-4" />
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
import * as React from "react";
import { ListTodo } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
// TodoPopover shows the latest TodoWrite state of a session (chat conversation or
// task worker/planner replay). The full todo list isn't in the activity list
// (detail is lazy), so on open it fetches the detail of the most-recent TodoWrite
// call — by seq — and parses its {todos:[…]} JSON. Purely client-side; the caller
// supplies the seq + a detail fetcher (conversation or exploration endpoint).
export function TodoPopover({
seq,
fetchDetail,
}: {
seq: number | null;
fetchDetail: (seq: number) => Promise<string>;
}) {
const [open, setOpen] = React.useState(false);
const [todos, setTodos] = React.useState<{ content: string; status: string }[] | null>(null);
const [loading, setLoading] = React.useState(false);
const [err, setErr] = React.useState("");
const load = React.useCallback(async () => {
if (seq == null) return;
setLoading(true);
setErr("");
try {
const detail = await fetchDetail(seq);
const start = detail.indexOf("{"); // detail 可能带 "TodoWrite " 前缀
const parsed = JSON.parse(start >= 0 ? detail.slice(start) : detail);
setTodos(Array.isArray(parsed?.todos) ? parsed.todos : []);
} catch {
setErr("解析 Todo 失败");
setTodos(null);
} finally {
setLoading(false);
}
}, [seq, fetchDetail]);
// refetch on each open — todos change as the run progresses.
React.useEffect(() => {
if (open) load();
}, [open, load]);
const disabled = seq == null;
const MARK: Record<string, string> = { pending: "☐", in_progress: "▶", completed: "✔" };
return (
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
title={disabled ? "本会话暂无 Todo" : "查看最近 Todo"}
className="text-muted-foreground/70 hover:text-primary flex items-center gap-0.5 text-xs disabled:pointer-events-none disabled:opacity-40"
>
<ListTodo className="size-3" />
Todo
</button>
</PopoverTrigger>
<PopoverContent align="end" className="max-h-80 w-80 overflow-auto p-2">
<p className="text-muted-foreground px-1 pb-1 text-[11px] font-medium">
最近 Todo{loading ? " · 加载中…" : ""}
</p>
{err && <p className="text-destructive px-1 text-xs">{err}</p>}
{todos && todos.length === 0 && !loading && (
<p className="text-muted-foreground px-1 text-xs">(空)</p>
)}
<ul className="space-y-0.5">
{(todos ?? []).map((t, i) => (
<li
key={`${i}:${t.content}`}
className={cn("flex gap-1.5 px-1 text-xs", t.status === "completed" && "text-muted-foreground line-through")}
>
<span className="shrink-0">{MARK[t.status] ?? "☐"}</span>
<span className="break-words">{t.content}</span>
</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}
+561
View File
@@ -0,0 +1,561 @@
"use client";
import * as React from "react";
import {
CheckIcon,
ChevronDown,
ChevronRight,
CrosshairIcon,
Flag,
MessageSquare,
ShieldAlertIcon,
Terminal,
UserIcon,
Wrench,
XIcon,
} from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { Markdown } from "@/components/markdown";
import { Button } from "@/components/ui/button";
import type { Activity } from "@/lib/types";
// ---- per-agent lane color (planner + work#1/#2/#3 …) ---------------------------
const workerColors = [
"bg-sky-600",
"bg-violet-600",
"bg-teal-600",
"bg-pink-600",
"bg-orange-600",
];
function workerColor(name: string): string {
if (name === "planner") return "bg-amber-600"; // the intent generator, distinct
if (name === "mainagent") return "bg-primary";
const m = /#(\d+)/.exec(name);
const i = m ? (parseInt(m[1], 10) - 1) % workerColors.length : 0;
return workerColors[Math.max(0, i)];
}
const chip = (worker: string) =>
"mt-0.5 shrink-0 rounded px-1 text-[9px] font-medium text-white " + workerColor(worker);
// A tool group pairs a tool_use with its matching tool_result (by tool_use_id);
// a run of consecutive conversational steps (text/thinking/result) from the same
// agent is one "message".
type Group =
| { type: "user"; key: number; step: Activity; intent?: boolean }
| { type: "answer"; key: number; step: Activity }
| { type: "round"; key: number; label: string }
| { type: "tool"; key: number; worker: string; use?: Activity; result?: Activity }
| { type: "msg"; key: number; worker: string; steps: Activity[] }
| { type: "intercept"; key: number; step: Activity };
function groupSteps(steps: Activity[], chat: boolean): Group[] {
const out: Group[] = [];
const byToolId = new Map<string, Extract<Group, { type: "tool" }>>();
for (const s of steps) {
if (s.kind === "usage") continue; // live token-usage marker — not a rendered step
if (s.kind === "round") {
out.push({ type: "round", key: s.seq, label: s.summary || "新一轮" }); // planner round boundary
continue;
}
if (s.kind === "intercept_request") {
out.push({ type: "intercept", key: s.seq, step: s });
continue;
}
if (s.kind === "user" || s.kind === "intent") {
// human turn OR the LLM-generated intent leading a worker session — both are
// right-aligned bubbles; `intent` swaps the avatar to a non-human icon.
out.push({ type: "user", key: s.seq, step: s, intent: s.kind === "intent" });
continue;
}
// In a chat (main agent) the assistant's text/result IS the answer — render it
// full (markdown), never collapsed. thinking still folds into a compact block.
if (s.kind === "result" || (chat && s.kind === "text")) {
out.push({ type: "answer", key: s.seq, step: s });
continue;
}
if (s.kind === "tool_use") {
const g: Extract<Group, { type: "tool" }> = { type: "tool", key: s.seq, worker: s.worker, use: s };
if (s.tool_use_id) byToolId.set(s.tool_use_id, g);
out.push(g);
continue;
}
if (s.kind === "tool_result") {
// bind to its tool_use by id (NOT adjacency — tools can run in parallel)
const g = s.tool_use_id ? byToolId.get(s.tool_use_id) : undefined;
if (g && !g.result) g.result = s;
else out.push({ type: "tool", key: s.seq, worker: s.worker, result: s }); // orphan result
continue;
}
const last = out[out.length - 1];
if (last && last.type === "msg" && last.worker === s.worker) last.steps.push(s);
else out.push({ type: "msg", key: s.seq, worker: s.worker, steps: [s] });
}
return out;
}
const kindLabel = (k: string) => (k === "thinking" ? "推理" : k === "result" ? "总结" : "说明");
// toolInputText renders a tool_use input for display. For Bash it pulls the shell
// command out of the raw input JSON ({"command":…,"description":…}) so the UI shows
// the command itself instead of JSON; other tools fall back to the raw text.
function toolInputText(tool: string, raw: string): string {
if (tool !== "Bash") return raw;
// full input (expanded detail): parse and pull the command out.
try {
const o = JSON.parse(raw);
if (o && typeof o.command === "string") return o.command;
} catch {
// collapsed-row summaries are truncated to ~200 chars by the backend, so the
// JSON tail is cut off and JSON.parse fails — fall through and extract the
// "command" field by hand, tolerating the missing closing quote.
}
const m = raw.match(/"command"\s*:\s*"((?:\\.|[^"\\])*)/);
if (m) {
try {
// re-wrap the captured body and parse to unescape \n, \", \\, etc.
return JSON.parse('"' + m[1] + '"');
} catch {
// truncated mid-escape — unescape the common sequences best-effort.
return m[1].replace(/\\(["\\/nrt])/g, (_s, c) =>
c === "n" ? "\n" : c === "r" ? "\r" : c === "t" ? "\t" : c,
);
}
}
return raw;
}
// InterceptCard renders an inline intercept_request approval card. The pending_id
// is extracted from the summary (format: "工具 X 请求审批 (#N)") so buttons are
// available immediately without waiting for the detail load.
function InterceptCard({
step,
getDetail,
}: {
step: Activity;
getDetail: (seq: number) => Promise<string>;
}) {
// extract pending_id from summary: "工具 Bash 请求审批 (#42)"
const pendingId = React.useMemo(() => {
const m = /\(#(\d+)\)/.exec(step.summary);
return m ? parseInt(m[1], 10) : null;
}, [step.summary]);
const toolName = React.useMemo(() => {
const m = /工具\s+(\S+)\s+请求/.exec(step.summary);
return m ? m[1] : step.summary;
}, [step.summary]);
const [detail, setDetail] = React.useState<Record<string, unknown> | null>(null);
const [decided, setDecided] = React.useState<"allowed" | "denied" | "timeout" | null>(null);
const [deciding, setDeciding] = React.useState(false);
// Load persisted detail JSON + check the real current status from the backend
// so that a page refresh shows the already-decided state instead of re-offering buttons.
React.useEffect(() => {
let live = true;
getDetail(step.seq)
.then((raw) => {
if (!live || !raw) return;
try { setDetail(JSON.parse(raw)); } catch { /* ignore */ }
})
.catch(() => {/* ignore */});
return () => { live = false; };
}, [step.seq, getDetail]);
React.useEffect(() => {
if (!pendingId) return;
let live = true;
api.interceptGetOne(pendingId)
.then((p) => {
if (!live) return;
if (p.status !== "pending") setDecided(p.status as "allowed" | "denied" | "timeout");
})
.catch(() => {/* ignore */});
return () => { live = false; };
}, [pendingId]);
async function decide(decision: "allowed" | "denied") {
if (!pendingId || deciding) return;
setDeciding(true);
try {
await api.interceptDecide(pendingId, decision);
setDecided(decision);
toast.success(decision === "allowed" ? "已允许执行" : "已拒绝执行");
} catch (e) {
toast.error((e as Error).message);
} finally {
setDeciding(false);
}
}
const inputStr = detail?.input
? JSON.stringify(detail.input).slice(0, 200)
: null;
return (
<div className="my-2 rounded-lg border border-amber-400/50 bg-amber-50/40 dark:bg-amber-950/15 p-3 text-xs">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2 min-w-0">
<ShieldAlertIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-1.5 font-medium">
<span className="text-amber-700 dark:text-amber-400">审批请求</span>
<code className="rounded bg-amber-100 dark:bg-amber-900/50 px-1 font-mono text-amber-800 dark:text-amber-300">
{toolName}
</code>
{pendingId && (
<span className="text-muted-foreground">#{pendingId}</span>
)}
</div>
{inputStr && (
<p className="font-mono text-muted-foreground truncate">{inputStr}</p>
)}
</div>
</div>
{decided ? (
<span className={
"shrink-0 rounded px-2 py-0.5 text-[11px] font-medium " +
(decided === "allowed"
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400"
: decided === "timeout"
? "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400"
: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400")
}>
{decided === "allowed" ? "已允许" : decided === "timeout" ? "已超时" : "已拒绝"}
</span>
) : (
<div className="flex shrink-0 gap-1.5">
<Button
size="sm"
className="h-6 px-2 text-xs"
disabled={deciding || !pendingId}
onClick={() => decide("allowed")}
>
<CheckIcon className="h-3 w-3" />
允许
</Button>
<Button
size="sm"
variant="destructive"
className="h-6 px-2 text-xs"
disabled={deciding || !pendingId}
onClick={() => decide("denied")}
>
<XIcon className="h-3 w-3" />
拒绝
</Button>
</div>
)}
</div>
</div>
);
}
// ToolBlock renders ONE tool call: the command and its result bound into a single
// row (collapsed shows the command + a status/result preview; expand shows the
// full input AND output together). Lazy-loads both details on expand.
function ToolBlock({
group,
getDetail,
showWorker,
}: {
group: Extract<Group, { type: "tool" }>;
getDetail: (seq: number) => Promise<string>;
showWorker?: boolean;
}) {
const [open, setOpen] = React.useState(false);
const [detail, setDetail] = React.useState<string | null>(null);
// what we last loaded, keyed by the underlying step seqs. When the tool result
// arrives after we expanded mid-run (command only), this key changes and the
// effect below re-fetches — so the output shows up instead of being cached out.
const loadedKey = React.useRef<string | null>(null);
const { use, result } = group;
const toolName = use?.tool || result?.tool || "工具";
const ToolIcon = toolName === "Bash" ? Terminal : Wrench;
const running = !result;
const ok = !!result && !result.is_error;
const statusTone = running
? "text-muted-foreground"
: ok
? "text-emerald-600 dark:text-emerald-400"
: "text-red-600 dark:text-red-400";
const rawCmd =
use && use.summary.startsWith(toolName) ? use.summary.slice(toolName.length).trimStart() : use?.summary ?? "";
const cmd = toolInputText(toolName, rawCmd);
// status only — the full result lives behind the expand (【输出】), not previewed inline
const statusText = running ? "执行中…" : ok ? "✓" : "✕ 失败";
// key over the seqs we'd load; changes when the result (or command) arrives.
const detailKey = `${use?.seq ?? ""}:${result?.seq ?? ""}`;
React.useEffect(() => {
if (!open || loadedKey.current === detailKey) return;
let live = true;
const segs: { label: string; seq: number }[] = [];
if (use) segs.push({ label: "命令", seq: use.seq });
if (result) segs.push({ label: "输出" + (result.is_error ? " ✕" : " ✓"), seq: result.seq });
Promise.all(
segs.map((x) =>
getDetail(x.seq)
.then((d) => d || "(空)")
.catch(() => "(加载失败)"),
),
).then((parts) => {
if (!live) return;
setDetail(
segs
.map((x, i) => `【${x.label}】\n${x.label === "命令" ? toolInputText(toolName, parts[i]) : parts[i]}`)
.join("\n\n"),
);
loadedKey.current = detailKey;
});
return () => {
live = false;
};
}, [open, detailKey, use, result, getDetail]);
function toggle() {
setOpen((o) => !o);
}
return (
<div className="text-xs">
<button onClick={toggle} className="flex w-full items-start gap-2 py-1 text-left hover:bg-muted/40">
<span className="mt-0.5 text-muted-foreground">
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
</span>
<ToolIcon className={"mt-0.5 size-3.5 shrink-0 " + (running ? "text-sky-600 dark:text-sky-400" : statusTone)} />
{showWorker && <span className={chip(group.worker)}>{group.worker}</span>}
<span className="shrink-0 font-medium text-sky-600 dark:text-sky-400">{toolName}</span>
{cmd && <span className="min-w-0 flex-1 truncate font-mono text-muted-foreground">{cmd}</span>}
<span className={"ml-auto shrink-0 font-medium " + statusTone}>{statusText}</span>
</button>
{open && (
<pre className="ml-7 mb-1 max-h-72 overflow-auto whitespace-pre-wrap break-all rounded bg-muted/50 p-2 font-mono text-[11px] leading-relaxed">
{detail ?? "加载中…"}
</pre>
)}
</div>
);
}
// MessageBlock renders a coalesced agent message (merged streaming text/thinking/
// result fragments into one block) so the conversation reads as messages, not rows.
function MessageBlock({
group,
getDetail,
showWorker,
}: {
group: Extract<Group, { type: "msg" }>;
getDetail: (seq: number) => Promise<string>;
showWorker?: boolean;
}) {
const [open, setOpen] = React.useState(false);
const [detail, setDetail] = React.useState<string | null>(null);
// like ToolBlock: keyed by the group's step seqs so streamed steps arriving
// after an early expand re-fetch instead of being cached out.
const loadedKey = React.useRef<string | null>(null);
const speak = group.steps.filter((s) => s.kind !== "thinking");
const hasThinking = group.steps.some((s) => s.kind === "thinking");
const isError = group.steps.some((s) => s.is_error);
const body = (speak.length ? speak : group.steps).map((s) => s.summary).join(" ") || "…";
const Icon = isError ? Flag : MessageSquare;
const tone = isError ? "text-red-600 dark:text-red-400" : "text-foreground";
const detailKey = group.steps.map((s) => s.seq).join(",");
React.useEffect(() => {
if (!open || loadedKey.current === detailKey) return;
let live = true;
Promise.all(
group.steps.map((s) =>
getDetail(s.seq)
.then((d) => d || s.summary)
.catch(() => s.summary),
),
).then((parts) => {
if (!live) return;
setDetail(group.steps.map((s, i) => `【${kindLabel(s.kind)}】\n${parts[i]}`).join("\n\n"));
loadedKey.current = detailKey;
});
return () => {
live = false;
};
}, [open, detailKey, group.steps, getDetail]);
function toggle() {
setOpen((o) => !o);
}
return (
<div className="text-xs">
<button onClick={toggle} className="flex w-full items-start gap-2 py-1 text-left hover:bg-muted/40">
<span className="mt-0.5 text-muted-foreground">
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
</span>
<Icon className={"mt-0.5 size-3.5 shrink-0 " + tone} />
{showWorker && <span className={chip(group.worker)}>{group.worker}</span>}
<span className={"min-w-0 flex-1 truncate " + tone}>
{body}
{hasThinking && <span className="ml-1 text-[10px] text-muted-foreground">· 含推理</span>}
</span>
</button>
{open && (
<pre className="ml-7 mb-1 max-h-72 overflow-auto whitespace-pre-wrap break-all rounded bg-muted/50 p-2 font-mono text-[11px] leading-relaxed">
{detail ?? "加载中…"}
</pre>
)}
</div>
);
}
// UserRow renders a right-aligned chat bubble: either a human turn (the message you
// sent the main agent) or, in a worker session, the LLM-generated intent that leads
// it (intent=true) — same bubble, but a target icon instead of the human avatar.
// summary is a truncated first line, so the full message is pulled from the detail
// and shown in full (bubble is whitespace-pre-wrap, so long/multi-line text wraps).
function UserRow({ step, intent, getDetail }: { step: Activity; intent?: boolean; getDetail: (seq: number) => Promise<string> }) {
const Icon = intent ? CrosshairIcon : UserIcon;
const [full, setFull] = React.useState<string | null>(null);
React.useEffect(() => {
let live = true;
getDetail(step.seq)
.then((d) => {
if (live) setFull(d || step.summary);
})
.catch(() => {
if (live) setFull(step.summary);
});
return () => {
live = false;
};
}, [step.seq, getDetail, step.summary]);
return (
<div className="mt-3 mb-2 flex justify-end gap-2">
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-lg rounded-tr-sm bg-primary px-3 py-1.5 text-sm text-primary-foreground">
{full ?? step.summary}
</div>
<div className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10">
<Icon className="size-3.5 text-primary" />
</div>
</div>
);
}
// AnswerBlock renders the agent's FINAL answer (kind="result") in full — never
// collapsed. The summary is a truncated first line, so the full text is pulled
// from the detail and shown inline.
function AnswerBlock({ step, getDetail }: { step: Activity; getDetail: (seq: number) => Promise<string> }) {
const [full, setFull] = React.useState<string | null>(null);
React.useEffect(() => {
let live = true;
getDetail(step.seq)
.then((d) => {
if (live) setFull(d || step.summary);
})
.catch(() => {
if (live) setFull(step.summary);
});
return () => {
live = false;
};
}, [step.seq, getDetail, step.summary]);
return (
<div className="mb-2 mt-1 flex">
<div
className={
"min-w-0 flex-1 break-words rounded-lg bg-muted px-3 py-2 " +
(step.is_error ? "text-sm text-red-600 dark:text-red-400" : "")
}
>
{step.is_error ? (
<span className="whitespace-pre-wrap">{full ?? step.summary}</span>
) : (
<Markdown text={full ?? step.summary} />
)}
</div>
</div>
);
}
// ExecView renders an agent execution replay (planner / worker / main agent) in
// the compact, grouped, expand-to-detail format — thinking, tool calls/results,
// and (for the main agent) the human turns. Worker lane chips show only when the
// view actually mixes agents.
function ExecView({
activity,
taskId,
chat,
fetchDetail,
}: {
activity: Activity[];
taskId?: string;
chat?: boolean;
fetchDetail?: (seq: number) => Promise<string>;
}) {
const showWorker = new Set(activity.map((a) => a.worker)).size > 1;
// default detail fetcher: the task-scoped activity endpoint. The chat page passes
// its own (conversation-scoped) fetcher instead.
const getDetail = React.useCallback(
(seq: number) =>
fetchDetail ? fetchDetail(seq) : api.activityDetail(seq, taskId).then((r) => r.detail ?? ""),
[fetchDetail, taskId],
);
return (
<div className="flex flex-col">
{groupSteps(activity, !!chat).map((g) =>
g.type === "round" ? (
<div key={"r" + g.key} className="my-2 flex items-center gap-2 text-[10px] font-medium text-muted-foreground">
<span className="h-px flex-1 bg-border" />
{g.label}
<span className="h-px flex-1 bg-border" />
</div>
) : g.type === "user" ? (
<UserRow key={"u" + g.key} step={g.step} intent={g.intent} getDetail={getDetail} />
) : g.type === "answer" ? (
<AnswerBlock key={"a" + g.key} step={g.step} getDetail={getDetail} />
) : g.type === "tool" ? (
<ToolBlock key={"t" + g.key} group={g} getDetail={getDetail} showWorker={showWorker} />
) : g.type === "intercept" ? (
<InterceptCard key={"ic" + g.key} step={g.step} getDetail={getDetail} />
) : (
<MessageBlock key={"m" + g.key} group={g} getDetail={getDetail} showWorker={showWorker} />
),
)}
</div>
);
}
// Transcript renders a session's activity as a compact grouped execution replay:
// human turns, thinking, tool calls (command+result paired), and messages — for
// the main agent (interactive) and worker/planner (read-only) alike.
export function Transcript({
activity,
live,
taskId,
chat,
fetchDetail,
}: {
activity: Activity[];
live?: boolean;
taskId?: string;
chat?: boolean;
fetchDetail?: (seq: number) => Promise<string>;
}) {
return (
<div className="flex flex-col gap-1">
<ExecView activity={activity} taskId={taskId} chat={chat} fetchDetail={fetchDetail} />
{live && (
<div className="flex items-center gap-2 pl-2 pt-1 text-xs text-muted-foreground">
<span className="flex gap-1">
<span className="size-1.5 animate-bounce rounded-full bg-blue-500 [animation-delay:-0.3s]" />
<span className="size-1.5 animate-bounce rounded-full bg-blue-500 [animation-delay:-0.15s]" />
<span className="size-1.5 animate-bounce rounded-full bg-blue-500" />
</span>
实时流式中…
</div>
)}
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client"
import * as React from "react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col", className)}
{...props}
/>
)
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
className={cn(
"h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+199
View File
@@ -0,0 +1,199 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
+76
View File
@@ -0,0 +1,76 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
+11
View File
@@ -0,0 +1,11 @@
"use client"
import { AspectRatio as AspectRatioPrimitive } from "radix-ui"
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }
+204
View File
@@ -0,0 +1,204 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
const attachmentVariants = cva(
"group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed",
{
variants: {
size: {
default:
"gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2",
sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5",
xs: "gap-1.5 rounded-lg text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1",
},
orientation: {
horizontal: "min-w-40 items-center",
vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30",
},
},
}
)
function Attachment({
className,
state = "done",
size = "default",
orientation = "horizontal",
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof attachmentVariants> & {
state?: "idle" | "uploading" | "processing" | "error" | "done"
}) {
return (
<div
data-slot="attachment"
data-state={state}
data-size={size}
data-orientation={orientation}
className={cn(attachmentVariants({ size, orientation }), className)}
{...props}
/>
)
}
const attachmentMediaVariants = cva(
"relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5",
{
variants: {
variant: {
icon: "",
image:
"opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover",
},
},
defaultVariants: {
variant: "icon",
},
}
)
function AttachmentMedia({
className,
variant = "icon",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof attachmentMediaVariants>) {
return (
<div
data-slot="attachment-media"
data-variant={variant}
className={cn(attachmentMediaVariants({ variant }), className)}
{...props}
/>
)
}
function AttachmentContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-content"
className={cn(
"max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1",
className
)}
{...props}
/>
)
}
function AttachmentTitle({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-title"
className={cn(
"block max-w-full min-w-0 truncate font-medium group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer",
className
)}
{...props}
/>
)
}
function AttachmentDescription({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-description"
className={cn(
"mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80",
"max-w-full",
className
)}
{...props}
/>
)
}
function AttachmentActions({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-actions"
className={cn(
"relative z-20 flex shrink-0 items-center group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 group-data-[orientation=vertical]/attachment:gap-1",
className
)}
{...props}
/>
)
}
function AttachmentAction({
className,
variant,
size = "icon-xs",
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="attachment-action"
variant={variant ?? "ghost"}
size={size}
className={cn(className)}
{...props}
/>
)
}
function AttachmentTrigger({
className,
asChild = false,
type,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="attachment-trigger"
type={asChild ? undefined : (type ?? "button")}
className={cn("absolute inset-0 z-10 outline-none", className)}
{...props}
/>
)
}
function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-group"
className={cn(
"flex min-w-0 scroll-fade-x snap-x snap-mandatory scroll-px-1 scrollbar-none gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start",
className
)}
{...props}
/>
)
}
export {
Attachment,
AttachmentGroup,
AttachmentMedia,
AttachmentContent,
AttachmentTitle,
AttachmentDescription,
AttachmentActions,
AttachmentAction,
AttachmentTrigger,
}
+112
View File
@@ -0,0 +1,112 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}
+49
View File
@@ -0,0 +1,49 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+122
View File
@@ -0,0 +1,122 @@
import * as React from "react"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
)
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? (
<ChevronRightIcon />
)}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"flex size-5 items-center justify-center [&>svg]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
+125
View File
@@ -0,0 +1,125 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="bubble-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
)
}
const bubbleVariants = cva(
"group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
{
variants: {
variant: {
default:
"*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
secondary:
"*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
muted:
"*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
tinted:
"*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
outline:
"*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
ghost:
"border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
destructive:
"*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Bubble({
variant = "default",
align = "start",
className,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof bubbleVariants> & {
align?: "start" | "end"
}) {
return (
<div
data-slot="bubble"
data-variant={variant}
data-align={align}
className={cn(bubbleVariants({ variant }), className)}
{...props}
/>
)
}
function BubbleContent({
asChild = false,
className,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="bubble-content"
className={cn(
"w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50",
className
)}
{...props}
/>
)
}
const bubbleReactionsVariants = cva(
"absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0",
{
variants: {
side: {
top: "top-0 -translate-y-3/4",
bottom: "bottom-0 translate-y-3/4",
},
align: {
start: "left-3",
end: "right-3",
},
},
defaultVariants: {
side: "bottom",
align: "end",
},
}
)
function BubbleReactions({
side = "bottom",
align = "end",
className,
...props
}: React.ComponentProps<"div"> & {
align?: "start" | "end"
side?: "top" | "bottom"
}) {
return (
<div
data-slot="bubble-reactions"
data-align={align}
data-side={side}
className={cn(bubbleReactionsVariants({ side, align }), className)}
{...props}
/>
)
}
export { BubbleGroup, Bubble, BubbleContent, BubbleReactions }
+83
View File
@@ -0,0 +1,83 @@
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"group/button-group flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg!",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg!",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
className={cn(
"flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
+67
View File
@@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+222
View File
@@ -0,0 +1,222 @@
"use client"
import * as React from "react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
type Locale,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString(locale?.code, { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative rounded-(--cell-radius)",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"font-medium select-none",
captionLayout === "label"
? "text-sm"
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] text-muted-foreground select-none",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
defaultClassNames.day
),
range_start: cn(
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn(
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
defaultClassNames.range_end
),
today: cn(
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon className={cn("size-4", className)} {...props} />
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: ({ ...props }) => (
<CalendarDayButton locale={locale} {...props} />
),
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+242
View File
@@ -0,0 +1,242 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "inset-y-0 -left-12 my-auto"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ChevronLeftIcon />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "inset-y-0 -right-12 my-auto"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ChevronRightIcon />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
useCarousel,
}
+373
View File
@@ -0,0 +1,373 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import type { TooltipValueType } from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
type TooltipNameType = number | string
export type ChartConfig = Record<
string,
{
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
>
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
initialDimension = INITIAL_DIMENSION,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
initialDimension?: {
width: number
height: number
}
}) {
const uniqueId = React.useId()
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer
initialDimension={initialDimension}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme ?? config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
} & Omit<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
TooltipNameType
>,
"accessibilityLayer"
>) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? (config[label]?.label ?? label)
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color ?? item.payload?.fill ?? item.color
return (
<div
key={index}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value != null && (
<span className="font-mono font-medium text-foreground tabular-nums">
{typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> & {
hideIcon?: boolean
nameKey?: string
} & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={index}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config ? config[configLabelKey] : config[key]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
+33
View File
@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+33
View File
@@ -0,0 +1,33 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+299
View File
@@ -0,0 +1,299 @@
"use client"
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
const Combobox = ComboboxPrimitive.Root
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</ComboboxPrimitive.Trigger>
)
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
asChild
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
>
<ComboboxTrigger />
</InputGroupButton>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
)
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className
)}
{...props}
/>
)
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
)
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
)
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
className
)}
{...props}
/>
)
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
)
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
}
+195
View File
@@ -0,0 +1,195 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@/components/ui/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+263
View File
@@ -0,0 +1,263 @@
"use client"
import * as React from "react"
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
className={cn("select-none", className)}
{...props}
/>
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn("z-50 max-h-(--radix-context-menu-content-available-height) min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn("z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
+168
View File
@@ -0,0 +1,168 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+22
View File
@@ -0,0 +1,22 @@
"use client"
import * as React from "react"
import { Direction } from "radix-ui"
function DirectionProvider({
dir,
direction,
children,
}: React.ComponentProps<typeof Direction.DirectionProvider> & {
direction?: React.ComponentProps<typeof Direction.DirectionProvider>["dir"]
}) {
return (
<Direction.DirectionProvider dir={direction ?? dir}>
{children}
</Direction.DirectionProvider>
)
}
const useDirection = Direction.useDirection
export { DirectionProvider, useDirection }
+134
View File
@@ -0,0 +1,134 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content fixed z-50 flex h-auto flex-col bg-popover text-sm text-popover-foreground data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm",
className
)}
{...props}
>
<div className="mx-auto mt-4 hidden h-1 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-0.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+269
View File
@@ -0,0 +1,269 @@
"use client"
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
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(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
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(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+104
View File
@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn("flex max-w-sm flex-col items-center gap-2", className)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn(
"font-heading text-sm font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
+238
View File
@@ -0,0 +1,238 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
+44
View File
@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import { HoverCard as HoverCardPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
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(
"z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }
+156
View File
@@ -0,0 +1,156 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+87
View File
@@ -0,0 +1,87 @@
"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { cn } from "@/lib/utils"
import { MinusIcon } from "lucide-react"
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"cn-input-otp flex items-center has-disabled:opacity-50",
containerClassName
)}
spellCheck={false}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn(
"flex items-center rounded-lg has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"relative flex size-8 items-center justify-center border-y border-r border-input text-sm transition-all outline-none first:rounded-l-lg first:border-l last:rounded-r-lg aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-3 data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-separator"
className="flex items-center [&_svg:not([class*='size-'])]:size-4"
role="separator"
{...props}
>
<MinusIcon
/>
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+196
View File
@@ -0,0 +1,196 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn(
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
className
)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-2", className)}
{...props}
/>
)
}
const itemVariants = cva(
"group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-100 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-muted",
{
variants: {
variant: {
default: "border-transparent",
outline: "border-border",
muted: "border-transparent bg-muted/50",
},
size: {
default: "gap-2.5 px-3 py-2.5",
sm: "gap-2.5 px-3 py-2.5",
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Item({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
)
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
{
variants: {
variant: {
default: "bg-transparent",
icon: "[&_svg:not([class*='size-'])]:size-4",
image:
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
)
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
className
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"line-clamp-2 text-left text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}
+26
View File
@@ -0,0 +1,26 @@
import { cn } from "@/lib/utils"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm bg-muted px-1 font-sans text-xs font-medium text-muted-foreground select-none in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 [&_svg:not([class*='size-'])]:size-3",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }
+24
View File
@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+69
View File
@@ -0,0 +1,69 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const markerVariants = cva(
"group/marker relative flex min-h-4 w-full items-center gap-2 text-left text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [a]:underline [a]:underline-offset-3 [a]:hover:text-foreground",
{
variants: {
variant: {
default: "",
separator:
"before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border",
border: "border-b border-border pb-2",
},
},
}
)
function Marker({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof markerVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="marker"
data-variant={variant}
className={cn(markerVariants({ variant, className }))}
{...props}
/>
)
}
function MarkerIcon({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="marker-icon"
aria-hidden="true"
className={cn(
"size-4 shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function MarkerContent({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="marker-content"
className={cn(
"min-w-0 wrap-break-word group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export { Marker, MarkerIcon, MarkerContent, markerVariants }
+280
View File
@@ -0,0 +1,280 @@
"use client"
import * as React from "react"
import { Menubar as MenubarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"flex h-8 items-center gap-0.5 rounded-lg border p-[3px]",
className
)}
{...props}
/>
)
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
)
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"flex items-center rounded-sm px-1.5 py-[2px] text-sm font-medium outline-hidden select-none hover:bg-muted aria-expanded:bg-muted",
className
)}
{...props}
/>
)
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn("z-50 min-w-36 origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", className )}
{...props}
/>
</MenubarPortal>
)
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/menubar-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function MenubarCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenubarPrimitive.ItemIndicator>
<CheckIcon
/>
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
)
}
function MenubarRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenubarPrimitive.ItemIndicator>
<CheckIcon
/>
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
)
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-sm font-medium data-inset:pl-7",
className
)}
{...props}
/>
)
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/menubar-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</MenubarPrimitive.SubTrigger>
)
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn("z-50 min-w-32 origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}
+131
View File
@@ -0,0 +1,131 @@
"use client"
import * as React from "react"
import {
MessageScroller as MessageScrollerPrimitive,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from "@shadcn/react/message-scroller"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ArrowDownIcon } from "lucide-react"
function MessageScrollerProvider(
props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>
) {
return <MessageScrollerPrimitive.Provider {...props} />
}
function MessageScroller({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Root>) {
return (
<MessageScrollerPrimitive.Root
data-slot="message-scroller"
className={cn(
"group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden",
className
)}
{...props}
/>
)
}
function MessageScrollerViewport({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Viewport>) {
return (
<MessageScrollerPrimitive.Viewport
data-slot="message-scroller-viewport"
className={cn(
"size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent",
className
)}
{...props}
/>
)
}
function MessageScrollerContent({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Content>) {
return (
<MessageScrollerPrimitive.Content
data-slot="message-scroller-content"
className={cn("flex h-max min-h-full flex-col gap-6", className)}
{...props}
/>
)
}
function MessageScrollerItem({
className,
scrollAnchor = false,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Item>) {
return (
<MessageScrollerPrimitive.Item
data-slot="message-scroller-item"
scrollAnchor={scrollAnchor}
className={cn(
"min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]",
className
)}
{...props}
/>
)
}
function MessageScrollerButton({
direction = "end",
className,
children,
render,
variant = "secondary",
size = "icon-sm",
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Button> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<MessageScrollerPrimitive.Button
data-slot="message-scroller-button"
data-direction={direction}
data-variant={variant}
data-size={size}
direction={direction}
className={cn(
"absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180",
className
)}
render={render ?? <Button variant={variant} size={size} />}
{...props}
>
{children ?? (
<>
<ArrowDownIcon
/>
<span className="sr-only">
{direction === "end" ? "Scroll to end" : "Scroll to start"}
</span>
</>
)}
</MessageScrollerPrimitive.Button>
)
}
export {
MessageScrollerProvider,
MessageScroller,
MessageScrollerViewport,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerButton,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
}
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function MessageGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
)
}
function Message({
className,
align = "start",
...props
}: React.ComponentProps<"div"> & { align?: "start" | "end" }) {
return (
<div
data-slot="message"
data-align={align}
className={cn(
"group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse",
className
)}
{...props}
/>
)
}
function MessageAvatar({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-avatar"
className={cn(
"flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8",
className
)}
{...props}
/>
)
}
function MessageContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-content"
className={cn(
"flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end",
className
)}
{...props}
/>
)
}
function MessageHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-header"
className={cn(
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0",
className
)}
{...props}
/>
)
}
function MessageFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="message-footer"
className={cn(
"flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end",
className
)}
{...props}
/>
)
}
export {
MessageGroup,
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageHeader,
}
+61
View File
@@ -0,0 +1,61 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { ChevronDownIcon } from "lucide-react"
type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & {
size?: "sm" | "default"
}
function NativeSelect({
className,
size = "default",
...props
}: NativeSelectProps) {
return (
<div
className={cn(
"group/native-select relative w-fit has-[select:disabled]:opacity-50",
className
)}
data-slot="native-select-wrapper"
data-size={size}
>
<select
data-slot="native-select"
data-size={size}
className="h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
{...props}
/>
<ChevronDownIcon className="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground select-none" aria-hidden="true" data-slot="native-select-icon" />
</div>
)
}
function NativeSelectOption({
className,
...props
}: React.ComponentProps<"option">) {
return (
<option
data-slot="native-select-option"
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
)
}
function NativeSelectOptGroup({
className,
...props
}: React.ComponentProps<"optgroup">) {
return (
<optgroup
data-slot="native-select-optgroup"
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
)
}
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption }
+164
View File
@@ -0,0 +1,164 @@
import * as React from "react"
import { cva } from "class-variance-authority"
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon } from "lucide-react"
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-0",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-lg px-2.5 py-1.5 text-sm font-medium transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted data-open:bg-muted/50 data-open:hover:bg-muted data-open:focus:bg-muted"
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon className="relative top-px ml-1 size-3 transition duration-300 group-data-popup-open/navigation-menu-trigger:rotate-180 group-data-open/navigation-menu-trigger:rotate-180" aria-hidden="true" />
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"top-0 left-0 w-full p-1 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-lg group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:ring-1 group-data-[viewport=false]/navigation-menu:ring-foreground/10 group-data-[viewport=false]/navigation-menu:duration-300 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none md:absolute md:w-auto group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95",
className
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center relative mt-1.5 h-(--radix-navigation-menu-viewport-height) w-full overflow-hidden rounded-lg bg-popover text-popover-foreground shadow ring-1 ring-foreground/10 duration-100 md:w-(--radix-navigation-menu-viewport-width) data-open:animate-in data-open:zoom-in-90 data-closed:animate-out data-closed:zoom-out-90",
className
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"flex items-center gap-2 rounded-lg p-2 text-sm transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 in-data-[slot=navigation-menu-content]:rounded-md data-active:bg-muted/50 data-active:hover:bg-muted data-active:focus:bg-muted [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"top-full z-1 flex h-1.5 items-end justify-center overflow-hidden data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}
+129
View File
@@ -0,0 +1,129 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex items-center gap-0.5", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<Button
asChild
variant={isActive ? "outline" : "ghost"}
size={size}
className={cn(className)}
>
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
{...props}
/>
</Button>
)
}
function PaginationPrevious({
className,
text = "Previous",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("pl-1.5!", className)}
{...props}
>
<ChevronLeftIcon data-icon="inline-start" />
<span className="hidden sm:block">{text}</span>
</PaginationLink>
)
}
function PaginationNext({
className,
text = "Next",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("pr-1.5!", className)}
{...props}
>
<span className="hidden sm:block">{text}</span>
<ChevronRightIcon data-icon="inline-end" />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn(
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}
+89
View File
@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 flex w-72 origin-(--radix-popover-content-transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import { Progress as ProgressPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="size-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }
+44
View File
@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid w-full gap-2", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="flex size-4 items-center justify-center"
>
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }
+50
View File
@@ -0,0 +1,50 @@
"use client"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: ResizablePrimitive.GroupProps) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full aria-[orientation=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border" />
)}
</ResizablePrimitive.Separator>
)
}
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
Loaded 100 of 287 files, more files were not shown because too many files have changed in this diff. Show more