chore: ARTEX 初始化
ARTEX — AI 自主渗透测试系统(Go 后端 + Next.js 前端,单二进制内嵌)。 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
commit
0ef8561411
287 files changed
+70178
No files matched your search
@@ -0,0 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export default function AuthLayout({ children }: { readonly children: ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} | ||||