First Commit
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s

This commit is contained in:
dela
2026-10-09 08:38:16 +08:00
commit 0335d572de
756 changed files with 201663 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
import type { ReactNode } from "react";
export default function AuthLayout({ children }: { readonly children: ReactNode }) {
return <>{children}</>;
}
+291
View File
@@ -0,0 +1,291 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { AlertTriangle, ShieldCheck } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
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 t = useTranslations("auth");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [checking, setChecking] = useState(true);
const [agreed, setAgreed] = useState(false);
const [termsOpen, setTermsOpen] = useState(false);
const [readToEnd, setReadToEnd] = useState(false);
const termsBodyRef = useRef<HTMLDivElement>(null);
// 滚动到条款底部(含无需滚动即可完整展示的情况)方可点击「同意」。
function handleTermsScroll() {
const el = termsBodyRef.current;
if (!el) return;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 8) setReadToEnd(true);
}
useEffect(() => {
if (!termsOpen) return;
// 打开时重置,并处理内容本就不足一屏、无法触发滚动的场景。
setReadToEnd(false);
const el = termsBodyRef.current;
if (el && el.scrollHeight <= el.clientHeight + 8) setReadToEnd(true);
}, [termsOpen]);
useEffect(() => {
// 已登录直接进主界面(静态导出下无 middleware 代劳这层跳转)。
const token = auth.getToken();
if (token) {
// localStorage 可能仍有凭据但 cookie 已丢失。先同步,再发起全新请求,
// 避免服务端守卫或路由缓存把跳转送回仍处于 checking 状态的登录页。
auth.setToken(token);
window.location.replace("/function/tasks");
return;
}
api
.authStatus()
.then(({ initialized }) => {
if (!initialized) router.replace("/setup");
})
.catch(() => setError(t("login.errorBackend")))
.finally(() => setChecking(false));
}, [router, t]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!agreed) {
setError(t("login.errorAgree"));
return;
}
setLoading(true);
setError("");
try {
const { token } = await api.login("ARTEX", password);
auth.setToken(token);
window.location.replace("/function/tasks");
} catch {
setError(t("login.errorCredential"));
} finally {
setLoading(false);
}
}
if (checking) {
return (
<div role="status" className="flex min-h-dvh items-center justify-center text-muted-foreground">
{t("login.checking")}
</div>
);
}
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">{t("login.title")}</h2>
<p className="mx-auto max-w-xl text-muted-foreground">{t("login.welcome")}</p>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="space-y-1.5">
<Label htmlFor="username">{t("login.username")}</Label>
<Input id="username" value="ARTEX" readOnly className="bg-muted text-muted-foreground" />
</div>
<div className="space-y-1.5">
<Label htmlFor="password">{t("login.password")}</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("login.passwordPlaceholder")}
autoFocus
autoComplete="current-password"
/>
</div>
<div className="flex items-start gap-2">
<Checkbox
id="agree-terms"
checked={agreed}
onCheckedChange={(v) => setAgreed(v === true)}
className="mt-0.5"
/>
<Label htmlFor="agree-terms" className="text-sm font-normal leading-relaxed text-muted-foreground">
{t.rich("login.agree", {
terms: (chunks) => (
<button
type="button"
onClick={() => setTermsOpen(true)}
className="mx-0.5 font-medium text-primary underline-offset-4 hover:underline"
>
{chunks}
</button>
),
})}
</Label>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading || !password || !agreed}>
{loading ? t("login.submitting") : t("login.submit")}
</Button>
</form>
</div>
</div>
<Dialog open={termsOpen} onOpenChange={setTermsOpen}>
<DialogContent className="gap-0 p-0 sm:max-w-2xl">
<DialogHeader className="flex-row items-center gap-3 border-b px-6 py-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<ShieldCheck className="size-5" />
</div>
<div className="space-y-0.5">
<DialogTitle className="text-base">{t("terms.title")}</DialogTitle>
<p className="text-xs text-muted-foreground">{t("terms.meta")}</p>
</div>
</DialogHeader>
<div
ref={termsBodyRef}
onScroll={handleTermsScroll}
className="max-h-[60vh] space-y-5 overflow-y-auto px-6 py-5 text-sm leading-relaxed text-muted-foreground"
>
<p className="rounded-lg border bg-muted/40 p-3 text-foreground/80">
{t("terms.intro")}
<span className="font-medium text-foreground"> {t("terms.introEmphasis")}</span>
</p>
<section className="space-y-1.5">
<h4 className="flex items-center gap-2 font-medium text-foreground">
<span className="flex size-5 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground">
1
</span>
{t("terms.a1Title")}
</h4>
<p className="pl-7">{t("terms.a1Body")}</p>
</section>
<section className="space-y-1.5">
<h4 className="flex items-center gap-2 font-medium text-foreground">
<span className="flex size-5 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground">
2
</span>
{t("terms.a2Title")}
</h4>
<p className="pl-7">{t("terms.a2Body")}</p>
</section>
<section className="space-y-2">
<h4 className="flex items-center gap-2 font-medium text-destructive">
<span className="flex size-5 items-center justify-center rounded-md bg-destructive/10 text-xs font-semibold text-destructive">
3
</span>
<AlertTriangle className="size-4" />
{t("terms.a3Title")}
</h4>
<ul className="ml-7 list-decimal space-y-1.5 rounded-lg border border-destructive/20 bg-destructive/5 p-3 pl-8 text-foreground/80 marker:text-destructive/70">
<li>{t("terms.a3Item1")}</li>
<li>{t("terms.a3Item2")}</li>
<li>{t("terms.a3Item3")}</li>
<li>{t("terms.a3Item4")}</li>
<li>{t("terms.a3Item5")}</li>
</ul>
</section>
<section className="space-y-1.5">
<h4 className="flex items-center gap-2 font-medium text-foreground">
<span className="flex size-5 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground">
4
</span>
{t("terms.a4Title")}
</h4>
<p className="pl-7">{t("terms.a4Body")}</p>
</section>
<section className="space-y-1.5">
<h4 className="flex items-center gap-2 font-medium text-foreground">
<span className="flex size-5 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground">
5
</span>
{t("terms.a5Title")}
</h4>
<p className="pl-7">{t("terms.a5Body")}</p>
</section>
<section className="space-y-1.5">
<h4 className="flex items-center gap-2 font-medium text-foreground">
<span className="flex size-5 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground">
6
</span>
{t("terms.a6Title")}
</h4>
<p className="pl-7">
{t("terms.a6Body")}
<span className="font-medium text-foreground"> {t("terms.a6Emphasis")}</span>
</p>
</section>
<section className="space-y-1.5">
<h4 className="flex items-center gap-2 font-medium text-foreground">
<span className="flex size-5 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground">
7
</span>
{t("terms.a7Title")}
</h4>
<p className="pl-7">{t("terms.a7Body")}</p>
</section>
<section className="space-y-1.5">
<h4 className="flex items-center gap-2 font-medium text-foreground">
<span className="flex size-5 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground">
8
</span>
{t("terms.a8Title")}
</h4>
<p className="pl-7">{t("terms.a8Body")}</p>
</section>
</div>
<DialogFooter className="mx-0 mb-0 flex-col items-stretch gap-2 rounded-b-xl px-6 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">
{readToEnd ? t("terms.footerRead") : t("terms.footerNotRead")}
</p>
<DialogClose asChild>
<Button
type="button"
disabled={!readToEnd}
onClick={() => {
setAgreed(true);
setError("");
}}
>
{t("terms.agreeButton")}
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
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 t = useTranslations("auth");
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(t("setup.errorBackend")))
.finally(() => setChecking(false));
}, [router, t]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (password !== confirm) {
setError(t("setup.errorMismatch"));
return;
}
if (password.length < 8) {
setError(t("setup.errorTooShort"));
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 : t("setup.errorInit"));
} 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">{t("setup.title")}</h2>
<p className="mx-auto max-w-xl text-muted-foreground">{t("setup.desc")}</p>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="space-y-1.5">
<Label htmlFor="password">{t("setup.newPassword")}</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("setup.newPasswordPlaceholder")}
autoFocus
autoComplete="new-password"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm">{t("setup.confirmPassword")}</Label>
<Input
id="confirm"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder={t("setup.confirmPlaceholder")}
autoComplete="new-password"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading || !password || !confirm}>
{loading ? t("setup.submitting") : t("setup.submit")}
</Button>
</form>
</div>
</div>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/function/tasks");
return <>Coming Soon</>;
}
@@ -0,0 +1,88 @@
"use client";
import { type ReactNode, useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { useCurrentUser } from "@/hooks/use-current-user";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import { AccountSwitcher } from "./sidebar/account-switcher";
import { LayoutControls } from "./sidebar/layout-controls";
import { SearchDialog } from "./sidebar/search-dialog";
import { ThemeSwitcher } from "./sidebar/theme-switcher";
import { UpdateBadge } from "./update-badge";
// 任务详情页保持原样:它自带头部/Tabs 与内边距,这里不再叠加全局头部和 padding。
function isFullBleed(pathname: string) {
const p = (() => {
try {
return decodeURIComponent(pathname);
} catch {
return pathname;
}
})();
// 静态导出开了 trailingSlash,列表页自身的 pathname 是 "/function/tasks/",
// 先去掉尾斜杠再判前缀,否则列表页会被误判成详情页而丢掉全局头部。
const normalized = p.replace(/\/+$/, "");
return normalized.startsWith("/function/tasks/");
}
export function MainContent({ children }: { children: ReactNode }) {
const currentUser = useCurrentUser();
const pathname = usePathname();
const t = useTranslations("header");
const [version, setVersion] = useState("");
useEffect(() => {
api
.health()
.then((h) => setVersion((h.version ?? "").replace(/^v(?=\d)/, "")))
.catch(() => setVersion(""));
}, []);
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">
{version && (
<span className="font-medium text-muted-foreground text-xs tabular-nums">
{t("version", { version })}
</span>
)}
<UpdateBadge />
<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,96 @@
"use client";
import { useState } from "react";
import { Check, KeyRound, LogOut } from "lucide-react";
import { useTranslations } from "next-intl";
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 t = useTranslations("userMenu");
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 />
{t("changePassword")}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
<LogOut />
{t("logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ChangePasswordDialog open={pwOpen} onOpenChange={setPwOpen} />
</>
);
}
@@ -0,0 +1,63 @@
"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 { useTranslations } from "next-intl";
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 t = useTranslations("changePasswordDialog");
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(t("errorRequired"));
return;
}
if (newPassword !== confirmPassword) {
toast.error(t("errorMismatch"));
return;
}
setSaving(true);
api
.changePassword(oldPassword, newPassword)
.then(() => {
toast.success(t("success"));
onOpenChange(false);
})
.catch((err) => toast.error(t("failed", { msg: (err as Error).message })))
.finally(() => setSaving(false));
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<form onSubmit={submit}>
<DialogHeader>
<DialogTitle>{t("title")}</DialogTitle>
<DialogDescription>{t.rich("description", { b: (chunks) => <b>{chunks}</b> })}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 py-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="cp-old">{t("currentPassword")}</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">{t("newPassword")}</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">{t("confirmPassword")}</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}>
{t("cancel")}
</Button>
<Button type="submit" disabled={saving}>
{saving ? t("submitting") : t("submit")}
</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,347 @@
"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 { useTranslations } from "next-intl";
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 { api } from "@/lib/api";
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 title: string;
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);
}
// 그룹 번호 → nav.group.* 메시지 키. 사이드바 라벨은 데이터(sidebar-items.ts)가 아니라
// 렌더 시점에 useTranslations 로 번역한다. 매핑이 없는 그룹은 원문 label 로 떨어진다.
const GROUP_MESSAGE_KEY: Record<number, string> = { 1: "function", 2: "system" };
export function NavMain({ items }: NavMainProps) {
const t = useTranslations("nav");
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_MESSAGE_KEY[group.id] ? t(`group.${GROUP_MESSAGE_KEY[group.id]}`) : 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 */
}
}
void 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) {
const t = useTranslations("nav");
const title = t(`item.${item.id}`);
return (
<SidebarMenuItem>
<SidebarMenuButton asChild aria-disabled={item.disabled} tooltip={title} isActive={isActive}>
<Link
prefetch={false}
href={item.url}
target={item.newTab ? "_blank" : undefined}
rel={item.newTab ? "noreferrer" : undefined}
>
<NavLinkIcon item={item} title={title} showFallback={showIconFallback} />
<span>{title}</span>
</Link>
</SidebarMenuButton>
{item.id === "approvals" ? <InterceptPendingBadge /> : <NavItemBadge badge={item.badge} />}
</SidebarMenuItem>
);
}
function NavLinkIcon({ item, title, showFallback }: NavLinkIconProps) {
const Icon = item.icon;
if (Icon) {
return <Icon />;
}
if (showFallback) {
return <CollapsedIconFallback title={title} />;
}
return null;
}
function NavDropdownItem({ item, isActive, isSubItemActive }: NavDropdownItemProps) {
const t = useTranslations("nav");
const Icon = item.icon;
const title = t(`item.${item.id}`);
const subTitle = (id: string, fallback: string) => (t.has(`item.${id}`) ? t(`item.${id}`) : fallback);
return (
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton tooltip={title} isActive={isActive} disabled={item.disabled}>
{Icon ? <Icon /> : <CollapsedIconFallback title={title} />}
<span>{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>{subTitle(subItem.id, subItem.title)}</span>
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
);
}
function NavCollapsibleItem({ item, isActive, defaultOpen, isSubItemActive }: NavCollapsibleItemProps) {
const t = useTranslations("nav");
const Icon = item.icon;
const title = t(`item.${item.id}`);
const subTitle = (id: string, fallback: string) => (t.has(`item.${id}`) ? t(`item.${id}`) : fallback);
return (
<Collapsible asChild defaultOpen={defaultOpen} className="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={title} isActive={isActive} disabled={item.disabled}>
{Icon && <Icon />}
<span>{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>{subTitle(subItem.id, 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,97 @@
"use client";
import * as React from "react";
import { EllipsisVertical, KeyRound, LogOut } from "lucide-react";
import { useTranslations } from "next-intl";
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 t = useTranslations("userMenu");
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 />
{t("changePassword")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
<LogOut />
{t("logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
<ChangePasswordDialog open={pwOpen} onOpenChange={setPwOpen} />
</SidebarMenu>
);
}
@@ -0,0 +1,171 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { Search } from "lucide-react";
import { useTranslations } from "next-intl";
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";
// 표시 문자열은 데이터에 담지 않고 nav 네임스페이스 키만 담아 둔다. 실제 라벨·헤딩은
// 컴포넌트에서 useTranslations("nav") 로 렌더 시점에 번역한다(사이드바와 같은 메시지 원천).
type SearchItem = {
id: string;
headingKey: string; // nav 기준 상대 키 (그룹 헤딩). 예) "group.function"
labelKey: string; // nav 기준 상대 키 (항목 라벨). 예) "item.dashboard"
fallbackLabel: string; // 메시지에 키가 없을 때 쓸 원문
url: string;
icon?: NavMainItem["icon"];
disabled?: boolean;
newTab?: boolean;
};
// 그룹 번호 → nav.group.* 키. nav-main 과 같은 매핑을 쓴다.
const GROUP_MESSAGE_KEY: Record<number, string> = { 1: "function", 2: "system" };
function groupHeadingKey(groupId: number): string {
const k = GROUP_MESSAGE_KEY[groupId];
return k ? `group.${k}` : `group.${groupId}`;
}
const searchItems: SearchItem[] = sidebarItems.flatMap((group) => {
const headingKey = groupHeadingKey(group.id);
return group.items.flatMap((item) => {
if (item.subItems) {
return item.subItems.map((sub) => ({
id: sub.id,
headingKey,
labelKey: `item.${sub.id}`,
fallbackLabel: sub.title,
url: sub.url,
icon: item.icon,
disabled: sub.disabled,
newTab: sub.newTab,
}));
}
return [
{
id: item.id,
headingKey,
labelKey: `item.${item.id}`,
fallbackLabel: 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 headings = [...new Set(items.map((item) => item.headingKey))];
return headings.map((headingKey) => ({
headingKey,
items: items.filter((item) => item.headingKey === headingKey),
}));
}
export function SearchDialog() {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const router = useRouter();
const t = useTranslations("search");
const tNav = useTranslations("nav");
const labelOf = (item: SearchItem) => (tNav.has(item.labelKey) ? tNav(item.labelKey) : item.fallbackLabel);
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(({ headingKey, items: groupItems }, index) => {
const heading = tNav(headingKey);
return (
<React.Fragment key={headingKey}>
{index > 0 && <CommandSeparator />}
<CommandGroup heading={heading}>
{groupItems.map((item) => (
<CommandItem
disabled={item.disabled}
key={`${headingKey}-${item.id}`}
value={`${heading} ${labelOf(item)}`}
onSelect={() => handleSelect(item)}
>
<span className="flex min-w-0 items-center gap-2">
{item.icon && <item.icon />}
<span className="truncate">{labelOf(item)}</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" />
{t("button")}
<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={t("placeholder")} value={query} onValueChange={setQuery} />
<CommandList>
<CommandEmpty>{t("empty")}</CommandEmpty>
{query ? renderGroups(searchItems) : renderGroups(recommendations)}
</CommandList>
</Command>
</CommandDialog>
</>
);
}
@@ -0,0 +1,29 @@
import Link from "next/link";
import { siX } from "simple-icons";
import { SimpleIcon } from "@/components/simple-icon";
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export function SidebarSupportCard() {
return (
<Card size="sm" className="overflow-hidden shadow-none group-data-[collapsible=icon]:hidden">
<CardHeader className="min-w-0 px-4">
<CardTitle className="truncate text-sm">Looking for something more?</CardTitle>
<CardDescription className="line-clamp-2">
Open an issue or do reach out to me on&nbsp;
<Link
href="https://x.com/arhamkhnz"
target="_blank"
rel="noreferrer"
aria-label="Reach out on X"
className="inline-flex items-center text-foreground"
>
<SimpleIcon icon={siX} aria-hidden className="size-3 fill-current" />
</Link>
.
</CardDescription>
</CardHeader>
</Card>
);
}
@@ -0,0 +1,35 @@
"use client";
import { Monitor, Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import { persistPreference } from "@/lib/preferences/preferences-storage";
import { usePreferencesStore } from "@/stores/preferences/preferences-provider";
const THEME_CYCLE = ["light", "dark", "system"] as const;
export function ThemeSwitcher() {
const themeMode = usePreferencesStore((s) => s.themeMode);
const setThemeMode = usePreferencesStore((s) => s.setThemeMode);
const cycleTheme = () => {
const currentIndex = THEME_CYCLE.indexOf(themeMode);
const nextTheme = THEME_CYCLE[(currentIndex + 1) % THEME_CYCLE.length];
setThemeMode(nextTheme);
void persistPreference("theme_mode", nextTheme);
};
return (
<Button size="icon" onClick={cycleTheme} aria-label={`Current theme: ${themeMode}. Click to cycle themes`}>
{/* SYSTEM */}
<Monitor className="hidden [html[data-theme-mode=system]_&]:block" />
{/* DARK (resolved) */}
<Sun className="hidden dark:block [html[data-theme-mode=system]_&]:hidden" />
{/* LIGHT (resolved) */}
<Moon className="block dark:hidden [html[data-theme-mode=system]_&]:hidden" />
</Button>
);
}
@@ -0,0 +1,60 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { ArrowUpCircleIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { api } from "@/lib/api";
/**
* 顶栏的"有新版本"提示:整页加载时查一次,有更新就在版本号旁边亮出来,
* 点击直达系统配置页的「版本与更新」卡片。
*
* 后端对 GitHub 的查询结果有 30 分钟缓存,所以这里每次挂载都查一次是安全的
* ——未认证的 GitHub API 只有 60 次/小时/IP,没有那层缓存的话,多开几个标签页
* 就会把配额耗光,之后真想更新反而查不动。
*
* 查询失败一律静默:顶栏不是报错的地方,用户进设置页点「检查更新」会看到原因。
*/
export function UpdateBadge() {
const t = useTranslations("header");
const [latest, setLatest] = React.useState("");
React.useEffect(() => {
let alive = true;
api
.checkUpdate()
.then((r) => {
// has_update 已经包含了"版本号可比较"的判断,开发构建不会亮这个提示。
if (alive && r.has_update && r.latest) setLatest(r.latest.replace(/^v(?=\d)/, ""));
})
.catch(() => {
// 静默:没网 / GitHub 限流都不该在顶栏弹错误。
});
return () => {
alive = false;
};
}, []);
if (!latest) return null;
return (
<Link
href="/system/settings"
title={t("updateTitle", { latest })}
className="inline-flex items-center gap-1.5 rounded-full bg-primary px-2.5 py-1 font-medium text-primary-foreground text-xs transition-opacity hover:opacity-90"
>
{/* 呼吸点:顶栏元素很多,纯文字容易被忽略,动效让它一眼可见。 */}
<span className="relative flex size-1.5">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-primary-foreground opacity-75" />
<span className="relative inline-flex size-1.5 rounded-full bg-primary-foreground" />
</span>
<ArrowUpCircleIcon className="size-3.5" />
<span className="hidden sm:inline">{t("updateBadge", { latest })}</span>
<span className="sm:hidden">{t("updateBadgeShort")}</span>
</Link>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,399 @@
"use client";
import * as React from "react";
import { BarChart3Icon, ChevronLeftIcon, ChevronRightIcon, Loader2Icon, SearchIcon, TerminalIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api } from "@/lib/api";
import type { CommandRecord, ToolStat } from "@/lib/types";
import { cn } from "@/lib/utils";
function fmtTime(ts: string) {
return new Date(ts).toLocaleString("ko-KR", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
// toolInput renders a tool's raw input for display. Bash's {"command":"..."} is
// unwrapped to the bare command; other tools show their pretty-printed JSON args.
function toolInput(raw: string): string {
try {
const obj = JSON.parse(raw);
if (obj && typeof obj.command === "string") return obj.command;
return JSON.stringify(obj, null, 2);
} catch {
/* not JSON */
}
return raw;
}
// truncate clips a string to maxLen characters.
function truncate(s: string, maxLen: number): string {
const first = s.split("\n")[0]; // single-line preview
if (first.length <= maxLen) return first;
return first.slice(0, maxLen) + "…";
}
const PAGE_SIZES = [25, 50, 100];
const CMD_MAX_LEN = 80;
export default function CommandsPage() {
const t = useTranslations("commands");
const tp = useTranslations("pagination");
const [page, setPage] = React.useState(0);
const [size, setSize] = React.useState(50);
const [query, setQuery] = React.useState("");
const [queryQ, setQueryQ] = React.useState("");
const [taskFilter, setTaskFilter] = React.useState("");
const [commands, setCommands] = React.useState<CommandRecord[]>([]);
const [total, setTotal] = React.useState(0);
const [loading, setLoading] = React.useState(false);
// 도구별 호출 횟수. 팝업을 열 때만 가져온다(집계 질의가 한 번 더 들어가므로 페이지를 넘길 때마다 부담하지 않는다).
const [statsOpen, setStatsOpen] = React.useState(false);
const [stats, setStats] = React.useState<ToolStat[]>([]);
const [statsLoading, setStatsLoading] = React.useState(false);
// Selected execution is rendered in a right-side detail sheet.
const [selected, setSelected] = React.useState<CommandRecord | null>(null);
// Debounce search input.
React.useEffect(() => {
const t = setTimeout(() => setQueryQ(query.trim()), 300);
return () => clearTimeout(t);
}, [query]);
// Reset page on filter change.
// biome-ignore lint/correctness/useExhaustiveDependencies: every filter change intentionally resets pagination.
React.useEffect(() => {
setPage(0);
}, [queryQ, taskFilter, size]);
// Load data.
React.useEffect(() => {
let alive = true;
setLoading(true);
api
.commands({ task: taskFilter || undefined, q: queryQ || undefined, page, size })
.then((r) => {
if (!alive) return;
setCommands(r.commands ?? []);
setTotal(r.total ?? 0);
})
.catch(() => {
if (!alive) return;
setCommands([]);
setTotal(0);
})
.finally(() => alive && setLoading(false));
return () => {
alive = false;
};
}, [page, size, queryQ, taskFilter]);
// 통계는 필터 조건을 따라가며, 표가 보여 주는 것과 같은 기록 묶음을 대상으로 한다(단 페이지는 나누지 않는다).
React.useEffect(() => {
if (!statsOpen) return;
let alive = true;
setStatsLoading(true);
api
.commandStats({ task: taskFilter || undefined, q: queryQ || undefined })
.then((r) => alive && setStats(r.stats ?? []))
.catch(() => alive && setStats([]))
.finally(() => alive && setStatsLoading(false));
return () => {
alive = false;
};
}, [statsOpen, queryQ, taskFilter]);
const statsTotal = stats.reduce((n, s) => n + s.total, 0);
const statsErrors = stats.reduce((n, s) => n + s.errors, 0);
const statsMax = stats.reduce((n, s) => Math.max(n, s.total), 0);
const totalPages = Math.max(1, Math.ceil(total / size));
const rangeStart = total === 0 ? 0 : page * size + 1;
const rangeEnd = page * size + commands.length;
return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<TerminalIcon className="h-5 w-5 text-muted-foreground" />
<h1 className="text-xl font-semibold tracking-tight">{t("title")}</h1>
<Badge variant="secondary">{total}</Badge>
</div>
</div>
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-2">
<div className="relative max-w-sm flex-1">
<SearchIcon className="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={t("searchPlaceholder")}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-8 pl-8"
/>
</div>
<Input
placeholder={t("taskIdPlaceholder")}
className="h-8 w-28"
value={taskFilter}
onChange={(e) => setTaskFilter(e.target.value.replace(/\D/g, ""))}
/>
<Select value={String(size)} onValueChange={(v) => setSize(Number(v))}>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((n) => (
<SelectItem key={n} value={String(n)}>
{n}
{tp("perPage")}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" size="sm" className="h-8" onClick={() => setStatsOpen(true)}>
<BarChart3Icon className="size-4" />
{t("stats")}
</Button>
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
<span className="tabular-nums">
{rangeStart}–{rangeEnd} / {total}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<ChevronLeftIcon />
</Button>
<span className="tabular-nums">
{page + 1} / {totalPages}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page + 1 >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
>
<ChevronRightIcon />
</Button>
</div>
</div>
{/* History table */}
<div className="flex h-[calc(100vh-13rem)] min-h-0 flex-col">
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
<div className="min-h-0 flex-1 overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead className="w-[130px]">{t("colTime")}</TableHead>
<TableHead className="w-[60px]">{t("colTask")}</TableHead>
<TableHead className="w-[90px]">Worker</TableHead>
<TableHead className="w-[110px]">{t("colTool")}</TableHead>
<TableHead>{t("colInput")}</TableHead>
<TableHead className="w-[60px]">{t("colStatus")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && commands.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-12 text-center">
<Loader2Icon className="mx-auto h-5 w-5 animate-spin text-muted-foreground" />
</TableCell>
</TableRow>
) : commands.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-12 text-center text-sm text-muted-foreground">
{t("empty")}
</TableCell>
</TableRow>
) : (
commands.map((cmd) => (
<TableRow
key={cmd.id}
className={cn("cursor-pointer", selected?.id === cmd.id && "bg-accent hover:bg-accent")}
onClick={() => setSelected(cmd)}
>
<TableCell className="text-xs text-muted-foreground tabular-nums">
{fmtTime(cmd.created_at)}
</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground">#{cmd.exploration_id}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs font-mono">
{cmd.worker || "-"}
</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary" className="text-xs font-mono">
{cmd.tool || "-"}
</Badge>
</TableCell>
<TableCell className="max-w-0">
<code className="block truncate font-mono text-xs">
{truncate(toolInput(cmd.command), CMD_MAX_LEN)}
</code>
</TableCell>
<TableCell>
{cmd.is_error ? (
<Badge variant="destructive" className="text-xs">
{t("failed")}
</Badge>
) : (
<Badge variant="secondary" className="text-xs text-emerald-600">
{t("success")}
</Badge>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</Card>
</div>
{/* 도구 호출 통계: 표와 같은 기록 묶음(같은 필터, 페이지 나눔 없음) */}
<Dialog open={statsOpen} onOpenChange={setStatsOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("statsTitle")}</DialogTitle>
<DialogDescription>
{taskFilter || queryQ ? t("statsDescFiltered") : t("statsDescAll")}
{stats.length > 0 && (
<>
{" · "}
<span className="tabular-nums">{stats.length}</span>
{t("unitTools")} · <span className="tabular-nums">{statsTotal}</span>
{t("unitCalls")}
{statsErrors > 0 && (
<>
{" · "}
{t("failed")} <span className="tabular-nums text-red-600 dark:text-red-400">{statsErrors}</span>
</>
)}
</>
)}
</DialogDescription>
</DialogHeader>
{statsLoading && stats.length === 0 ? (
<div className="py-10 text-center">
<Loader2Icon className="mx-auto h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : stats.length === 0 ? (
<div className="py-10 text-center text-sm text-muted-foreground">{t("statsEmpty")}</div>
) : (
<div className="-mr-2 max-h-[55vh] space-y-1 overflow-auto pr-2">
{stats.map((s) => (
<div key={s.tool} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-md p-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-mono text-xs font-medium">{s.tool}</span>
{s.errors > 0 && (
<span className="text-[11px] tabular-nums text-red-600 dark:text-red-400">
{t("failed")} {s.errors}
</span>
)}
</div>
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${statsMax > 0 ? (s.total / statsMax) * 100 : 0}%` }}
/>
</div>
</div>
<div className="text-right">
<div className="text-xs font-semibold tabular-nums">{s.total}</div>
<div className="text-[11px] tabular-nums text-muted-foreground">
{statsTotal > 0 ? ((s.total / statsTotal) * 100).toFixed(1) : "0.0"}%
</div>
</div>
</div>
))}
</div>
)}
</DialogContent>
</Dialog>
<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[46rem]! sm:max-w-[46rem]!">
{selected && (
<>
<SheetHeader className="border-b px-5 py-4">
<SheetTitle className="pr-8">{t("detailTitle")}</SheetTitle>
<SheetDescription>{fmtTime(selected.created_at)}</SheetDescription>
<div className="flex flex-wrap items-center gap-2 pt-2">
<Badge variant="outline" className="text-xs font-mono">
#{selected.exploration_id}
</Badge>
<Badge variant="outline" className="text-xs font-mono">
{selected.worker || "-"}
</Badge>
<Badge variant="secondary" className="text-xs font-mono">
{selected.tool || "-"}
</Badge>
{selected.is_error ? (
<Badge variant="destructive" className="text-xs">
{t("failed")}
</Badge>
) : (
<Badge variant="secondary" className="text-xs text-emerald-600">
{t("success")}
</Badge>
)}
</div>
</SheetHeader>
<div className="grid min-h-0 flex-1 grid-rows-2 divide-y">
<div className="flex min-h-0 min-w-0 flex-col">
<div className="border-b px-5 py-2 text-xs font-medium text-muted-foreground">{t("inputLabel")}</div>
<div className="min-h-0 flex-1 overflow-auto">
<pre className="p-5 font-mono text-xs break-all whitespace-pre-wrap">
{toolInput(selected.command)}
</pre>
</div>
</div>
<div className="flex min-h-0 min-w-0 flex-col">
<div className="border-b px-5 py-2 text-xs font-medium text-muted-foreground">{t("outputLabel")}</div>
<div className="min-h-0 flex-1 overflow-auto">
<pre
className={cn(
"p-5 font-mono text-xs break-all whitespace-pre-wrap",
selected.is_error && "text-red-600 dark:text-red-400",
)}
>
{selected.output || t("emptyOutput")}
</pre>
</div>
</div>
</div>
</>
)}
</SheetContent>
</Sheet>
</div>
);
}
@@ -0,0 +1,375 @@
"use client";
import * as React from "react";
import {
BuildingIcon,
ChevronRightIcon,
CircleDashedIcon,
GlobeIcon,
LayoutTemplateIcon,
LinkIcon,
type LucideIcon,
NetworkIcon,
RefreshCwIcon,
SearchIcon,
SmartphoneIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import type { FindingAssetKind, FindingAssetNode } from "@/lib/types";
import { cn } from "@/lib/utils";
// 图标沿用资产页的类型映射,同一种资产在两处长得一样。
const KIND_ICON: Record<FindingAssetKind, LucideIcon> = {
company: BuildingIcon,
root_domain: GlobeIcon,
subdomain: GlobeIcon,
ip: NetworkIcon,
app: SmartphoneIcon,
service: LayoutTemplateIcon,
endpoint: LinkIcon,
none: CircleDashedIcon,
};
// TreeNode 是节点数组组装出来的树。后端已按「同父下发现多的在前」排好序,
// 这里只需按数组顺序挂载。
interface TreeNode extends FindingAssetNode {
children: TreeNode[];
depth: number;
/** 树上真正渲染的文字;完整 label 仍保留在 label 里(悬停提示与面包屑用)。 */
display: string;
}
function stripBrackets(host: string) {
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
}
function parseAssetURL(raw: string): URL | null {
try {
return new URL(raw);
} catch {
return null;
}
}
// hostOf 取一个节点代表的宿主:URL 取 hostname,「host:port」取 host,其余就是
// 标签本身(根域名 / 子域名 / IP)。
function hostOf(label: string): string {
const url = parseAssetURL(label);
if (url) return stripBrackets(url.hostname);
const hostPort = label.match(/^(.+):(\d+)$/);
return stripBrackets(hostPort ? hostPort[1] : label);
}
// shortLabel 去掉与父节点重复的前缀。service / endpoint 的 label 是完整 URL,而
// 宿主域名/IP 上一行已经写过了 —— 深层节点本来就窄,再把 host 重复一遍,真正有
// 信息量的端口和路径就全被截断掉了。完整值仍在 title 与面包屑里。
function shortLabel(node: FindingAssetNode, parent?: FindingAssetNode): string {
if (!parent) return node.label;
// 子域名挂在根域名下:去掉根域名后缀,只留自己那一段。
if (node.kind === "subdomain" && node.label.endsWith(`.${parent.label}`)) {
return node.label.slice(0, -(parent.label.length + 1)) || node.label;
}
if (node.kind !== "service" && node.kind !== "endpoint") return node.label;
// 父标签正好是自己的前缀(接口挂在同 URL 的服务下、服务挂在同 IP 下):直接砍掉。
if (node.label.startsWith(parent.label)) {
return node.label.slice(parent.label.length) || node.label;
}
// 否则只有父节点确实就是这个 URL 的宿主时才简写,不然会丢掉辨识信息
// (比如服务因为缺子域名资产行而直接挂在根域名下,那就得显示完整 URL)。
if (hostOf(node.label) !== hostOf(parent.label)) return node.label;
const url = parseAssetURL(node.label);
if (!url) return node.label;
if (node.kind === "endpoint") return `${url.pathname}${url.search}` || "/";
const scheme = url.protocol.replace(":", "");
const port = url.port || (url.protocol === "https:" ? "443" : "80");
return `${scheme} :${port}`;
}
export function buildAssetTree(nodes: FindingAssetNode[]): TreeNode[] {
const byKey = new Map<string, TreeNode>();
for (const node of nodes) {
byKey.set(node.key, { ...node, children: [], depth: 0, display: node.label });
}
const roots: TreeNode[] = [];
for (const node of nodes) {
const current = byKey.get(node.key);
if (!current) continue;
const parent = node.parent ? byKey.get(node.parent) : undefined;
// 父节点缺失(被截断层级丢掉)时上提为顶层,不让子树整个消失。
if (parent) {
parent.children.push(current);
current.display = shortLabel(node, parent);
} else {
roots.push(current);
}
}
const setDepth = (node: TreeNode, depth: number) => {
node.depth = depth;
for (const child of node.children) setDepth(child, depth + 1);
};
for (const root of roots) setDepth(root, 0);
return roots;
}
// assetPathOf 返回从顶层到该节点的路径,用于右侧面包屑。每一级同样只显示相对
// 上一级的增量(display),完整值留在 label 里。
export function assetPathOf(nodes: FindingAssetNode[], key: string | null): (FindingAssetNode & { display: string })[] {
if (!key) return [];
const byKey = new Map(nodes.map((n) => [n.key, n]));
const path: FindingAssetNode[] = [];
const seen = new Set<string>();
let current = byKey.get(key);
while (current && !seen.has(current.key)) {
seen.add(current.key);
path.unshift(current);
current = current.parent ? byKey.get(current.parent) : undefined;
}
return path.map((node, index) => ({ ...node, display: shortLabel(node, path[index - 1]) }));
}
// filterTree 按关键词过滤:命中的节点保留,并保留其整条祖先链(祖先自身可以不命中)。
// 命中节点的子孙一并保留,便于继续下钻。
function filterTree(nodes: TreeNode[], keyword: string): TreeNode[] {
const kw = keyword.trim().toLowerCase();
if (!kw) return nodes;
const walk = (node: TreeNode): TreeNode | null => {
const hit = node.label.toLowerCase().includes(kw);
if (hit) return node;
const children = node.children.map(walk).filter((c): c is TreeNode => c !== null);
if (children.length === 0) return null;
return { ...node, children };
};
return nodes.map(walk).filter((n): n is TreeNode => n !== null);
}
// collectKeys 收集一棵(子)树里的全部 key,用于「展开全部匹配项」。
function collectKeys(nodes: TreeNode[], out: Set<string> = new Set()): Set<string> {
for (const node of nodes) {
out.add(node.key);
collectKeys(node.children, out);
}
return out;
}
interface AssetTreeProps {
nodes: FindingAssetNode[];
selected: string | null;
onSelect: (key: string | null) => void;
loading?: boolean;
truncated?: boolean;
droppedKinds?: string[];
/** 未选中任何资产时右侧展示的发现总数,用于「全部资产」那一行。 */
findingTotal: number;
/** 资产视图不轮询,树的计数靠这个按钮或页面内的增删改来刷新。 */
onRefresh?: () => void;
}
export function AssetTree({
nodes,
selected,
onSelect,
loading,
truncated,
droppedKinds,
findingTotal,
onRefresh,
}: AssetTreeProps) {
const t = useTranslations("findings");
const kindLabel = (kind: string) => (t.has(`assetTree.kind.${kind}`) ? t(`assetTree.kind.${kind}`) : kind);
const [keyword, setKeyword] = React.useState("");
const [expanded, setExpanded] = React.useState<Set<string>>(() => new Set());
// 记住用户手动折叠过的节点,免得「默认展开顶层」在每次刷新后又把它们撑开。
const [collapsed, setCollapsed] = React.useState<Set<string>>(() => new Set());
const roots = React.useMemo(() => buildAssetTree(nodes), [nodes]);
const visible = React.useMemo(() => filterTree(roots, keyword), [roots, keyword]);
// 搜索时把匹配到的分支全部展开,否则命中项藏在折叠节点里等于没搜。
const searching = keyword.trim() !== "";
const searchKeys = React.useMemo(() => (searching ? collectKeys(visible) : null), [searching, visible]);
const isExpanded = React.useCallback(
(node: TreeNode) => {
if (searchKeys) return searchKeys.has(node.key);
if (expanded.has(node.key)) return true;
// 顶层默认展开一层:再深的层级要用户自己点开,免得一次铺开上千行。
return node.depth === 0 && !collapsed.has(node.key);
},
[collapsed, expanded, searchKeys],
);
const toggle = React.useCallback(
(node: TreeNode) => {
const open = isExpanded(node);
setExpanded((prev) => {
const next = new Set(prev);
if (open) next.delete(node.key);
else next.add(node.key);
return next;
});
setCollapsed((prev) => {
const next = new Set(prev);
if (open) next.add(node.key);
else next.delete(node.key);
return next;
});
},
[isExpanded],
);
let emptyHint = t("assetTree.emptyNoAsset");
if (loading) emptyHint = t("common.loading");
else if (searching) emptyHint = t("assetTree.emptyNoMatch");
const rows: React.ReactNode[] = [];
const pushRows = (list: TreeNode[]) => {
for (const node of list) {
const open = isExpanded(node);
rows.push(
<AssetTreeRow
key={node.key}
node={node}
open={open}
selected={selected === node.key}
onToggle={() => toggle(node)}
onSelect={() => onSelect(selected === node.key ? null : node.key)}
/>,
);
if (open && node.children.length > 0) pushRows(node.children);
}
};
pushRows(visible);
return (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<div className="flex items-center gap-1">
<InputGroup className="flex-1">
<InputGroupInput
type="search"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder={t("assetTree.filterPlaceholder")}
aria-label={t("assetTree.filterPlaceholder")}
/>
<InputGroupAddon>
<SearchIcon aria-hidden="true" />
</InputGroupAddon>
</InputGroup>
{onRefresh && (
<Button
size="icon"
variant="ghost"
className="size-8 shrink-0 text-muted-foreground"
onClick={onRefresh}
disabled={loading}
aria-label={t("assetTree.refresh")}
title={t("assetTree.refresh")}
>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
</Button>
)}
</div>
<button
type="button"
onClick={() => onSelect(null)}
className={cn(
"flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-sm",
selected === null ? "bg-accent font-medium" : "hover:bg-accent/50",
)}
>
<span>{t("asset.all")}</span>
<span className="text-xs tabular-nums text-muted-foreground">{findingTotal}</span>
</button>
<div className="max-h-[24rem] min-h-0 flex-1 overflow-y-auto pr-2 lg:max-h-[calc(100vh-16rem)]">
<div className="flex flex-col">
{rows}
{rows.length === 0 && <p className="px-2 py-8 text-center text-xs text-muted-foreground">{emptyHint}</p>}
</div>
</div>
{truncated && (
<p className="px-1 text-xs text-muted-foreground">
{t("assetTree.truncated", {
kinds: (droppedKinds ?? []).map((k) => kindLabel(k)).join(" / "),
})}
</p>
)}
</div>
);
}
function AssetTreeRow({
node,
open,
selected,
onToggle,
onSelect,
}: {
node: TreeNode;
open: boolean;
selected: boolean;
onToggle: () => void;
onSelect: () => void;
}) {
const t = useTranslations("findings");
const kindLabel = t.has(`assetTree.kind.${node.kind}`) ? t(`assetTree.kind.${node.kind}`) : node.kind;
const Icon = KIND_ICON[node.kind] ?? GlobeIcon;
const hasChildren = node.children.length > 0;
return (
<div
className={cn(
"group flex items-center gap-1 rounded-md pr-1 text-sm",
selected ? "bg-accent" : "hover:bg-accent/50",
)}
style={{ paddingLeft: `${node.depth * 10}px` }}
>
{hasChildren ? (
<button
type="button"
onClick={onToggle}
className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground"
aria-label={open ? t("assetTree.collapse") : t("assetTree.expand")}
aria-expanded={open}
>
<ChevronRightIcon className={cn("size-3.5 transition-transform", open && "rotate-90")} />
</button>
) : (
<span className="size-5 shrink-0" />
)}
<button
type="button"
onClick={onSelect}
className="flex min-w-0 flex-1 items-center gap-1.5 py-1 text-left"
title={`${kindLabel} · ${node.label}`}
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className={cn("min-w-0 truncate", selected && "font-medium")}>{node.display}</span>
</button>
<span className="flex shrink-0 items-center gap-1 text-xs tabular-nums">
{node.critical > 0 && (
<span className="text-rose-600" title={t("assetTree.criticalCount", { count: node.critical })}>
{node.critical}
</span>
)}
{node.high > 0 && (
<span className="text-red-500" title={t("assetTree.highCount", { count: node.high })}>
{node.high}
</span>
)}
<span className="text-muted-foreground" title={t("assetTree.totalCount", { count: node.total })}>
{node.total}
</span>
</span>
</div>
);
}
@@ -0,0 +1,457 @@
"use client";
import * as React from "react";
import Link from "next/link";
import {
ArrowUpRightIcon,
ChevronRightIcon,
FileTextIcon,
FlaskConicalIcon,
RotateCcwIcon,
ShieldAlertIcon,
Trash2Icon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { CopyButton } from "@/components/copy-button";
import { Markdown } from "@/components/markdown";
import { StatusBadge } from "@/components/status-badge";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import type { ActiveFindingRetest, Finding, FindingStatus, Severity } from "@/lib/types";
import { cn } from "@/lib/utils";
export const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"];
export const FINDING_STATUSES: FindingStatus[] = [
"pending",
"in_progress",
"confirmed",
"resolved",
"fixed",
"false_positive",
"ignored",
"duplicate",
"risk_accepted",
];
export const UNASSIGNED_TASK = "__unassigned__";
// 行内编辑缓冲:当前展开行的名称/类别/严重等级。
export interface FindingEdit {
name: string;
vulnclass: string;
severity: Severity;
}
export type FindingReport = { status: "loading" | "done" | "error"; text: string };
// Exploration node ids are only unique inside a task. Prefer the persisted
// finding id and otherwise namespace the node id by task so editing one group
// cannot update a similarly-named node in another expanded group.
export function findingRowKey(finding: Finding): string {
if (finding.finding_id) return `finding:${finding.finding_id}`;
return `node:${finding.task_id ?? UNASSIGNED_TASK}:${finding.id}`;
}
export function isSameFinding(left: Finding, right: Finding): boolean {
return findingRowKey(left) === findingRowKey(right);
}
export function fmtTime(ts: string) {
return new Date(ts).toLocaleString("ko-KR", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
const COLUMN_COUNT = 9;
interface FindingsTableProps {
items: Finding[];
selectedIds: Set<string>;
onToggleSelected: (id: string, checked: boolean) => void;
onToggleSelectedPage: (ids: string[], checked: boolean) => void;
/** 当前展开行的 findingRowKey;null = 全部收起。 */
expandedKey: string | null;
onToggleRow: (finding: Finding) => void;
reports: Record<string, FindingReport>;
edit: FindingEdit | null;
onEditChange: React.Dispatch<React.SetStateAction<FindingEdit | null>>;
saving: boolean;
onSave: (finding: Finding) => void;
onStatusChange: (finding: Finding, next: FindingStatus) => void;
onRetest: (finding: Finding) => void;
activeRetests: Record<string, ActiveFindingRetest>;
onDeepen: (finding: Finding) => void;
onDelete: (finding: Finding) => void;
/** 全选框的无障碍标签,平铺视图与分组视图措辞不同。 */
selectAllLabel?: string;
}
// FindingsTable 是发现列表的表格主体,平铺视图与按任务分组视图共用同一份行渲染
// (勾选 / 行内展开 / 行内改名与改状态 / 复测 / 深入 / 删除),差异只在外层容器与分页。
export function FindingsTable({
items,
selectedIds,
onToggleSelected,
onToggleSelectedPage,
expandedKey,
onToggleRow,
reports,
edit,
onEditChange,
saving,
onSave,
onStatusChange,
onRetest,
activeRetests,
onDeepen,
onDelete,
selectAllLabel,
}: FindingsTableProps) {
const t = useTranslations("findings");
const tStatus = useTranslations("status");
const resolvedSelectAllLabel = selectAllLabel ?? t("selectAllPage");
const selectableIds = items.map((finding) => finding.finding_id).filter((id): id is string => Boolean(id));
const selectedCount = selectableIds.filter((id) => selectedIds.has(id)).length;
let headerChecked: boolean | "indeterminate" = false;
if (selectableIds.length > 0 && selectedCount === selectableIds.length) {
headerChecked = true;
} else if (selectedCount > 0) {
headerChecked = "indeterminate";
}
return (
/* 固定列宽保证展开内容不撑开表格;窄屏只在表格内部横向滚动。 */
<Table className="min-w-[60rem] table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-8">
<Checkbox
checked={headerChecked}
onCheckedChange={(checked) => onToggleSelectedPage(selectableIds, checked === true)}
aria-label={resolvedSelectAllLabel}
/>
</TableHead>
<TableHead className="w-8" />
<TableHead className="w-20">{t("table.colSeverity")}</TableHead>
<TableHead>{t("table.colName")}</TableHead>
<TableHead className="w-44">{t("table.colAsset")}</TableHead>
<TableHead className="w-28">{t("table.colStatus")}</TableHead>
<TableHead className="w-32">{t("table.colTask")}</TableHead>
<TableHead className="w-24">{t("table.colTime")}</TableHead>
<TableHead className="w-48">{t("table.colActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((f) => {
const rowKey = findingRowKey(f);
const open = expandedKey === rowKey;
const retest = f.finding_id ? activeRetests[f.finding_id] : undefined;
return (
<React.Fragment key={rowKey}>
<TableRow
className="cursor-pointer"
role="button"
tabIndex={0}
aria-expanded={open}
onClick={() => onToggleRow(f)}
onKeyDown={(event) => {
if (event.target !== event.currentTarget || (event.key !== "Enter" && event.key !== " ")) return;
event.preventDefault();
onToggleRow(f);
}}
>
<TableCell onClick={(e) => e.stopPropagation()}>
{f.finding_id && (
<Checkbox
checked={selectedIds.has(f.finding_id)}
onCheckedChange={(c) => onToggleSelected(f.finding_id as string, c === true)}
aria-label={t("table.selectOne")}
/>
)}
</TableCell>
<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 className="max-w-md">
<div className="flex min-w-0 flex-col gap-0.5">
{f.finding_id ? (
<Link
href={`/function/findings/detail?id=${f.finding_id}`}
onClick={(e) => e.stopPropagation()}
className="truncate font-medium hover:text-primary hover:underline"
title={t("table.viewDetail")}
>
{f.name || f.vulnclass || t("table.unclassified")}
</Link>
) : (
<span className="truncate font-medium">{f.name || f.vulnclass || t("table.unclassified")}</span>
)}
<span className="truncate text-xs text-muted-foreground">{f.summary}</span>
<Badge variant="outline">{t("table.trafficEvidence", { count: f.traffic_count ?? 0 })}</Badge>
</div>
</TableCell>
<TableCell>
{f.assets && f.assets.length > 0 ? (
<div className="flex flex-wrap gap-1">
{f.assets.slice(0, 3).map((a) => (
<code
key={a.id}
className="max-w-full truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
title={`${a.type} · ${a.label}`}
>
{a.label}
</code>
))}
{f.assets.length > 3 && (
<span className="text-xs text-muted-foreground">+{f.assets.length - 3}</span>
)}
</div>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
{f.finding_id ? (
<Select value={f.status} onValueChange={(v) => onStatusChange(f, v as FindingStatus)}>
<SelectTrigger size="sm" className="h-7 w-full border-none px-1 shadow-none focus-visible:ring-0">
<StatusBadge domain="finding" value={f.status} dot />
</SelectTrigger>
<SelectContent position="popper" align="end">
{FINDING_STATUSES.map((st) => (
<SelectItem key={st} value={st}>
{tStatus(`finding.${st}`)}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<StatusBadge domain="finding" value={f.status} dot />
)}
</TableCell>
<TableCell>
{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>
<TableCell onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-1">
{retest ? (
<Button asChild size="sm" variant="ghost">
<Link href={`/chat?c=${retest.conversation_id}`} title={t("table.retestingTitle")}>
<Spinner data-icon="inline-start" />
{t("table.retesting")}
</Link>
</Button>
) : null}
{!retest && f.finding_id && !f.inherited ? (
<Button size="sm" variant="ghost" onClick={() => onRetest(f)} title={t("table.retestTitle")}>
<RotateCcwIcon data-icon="inline-start" />
{t("table.retest")}
</Button>
) : null}
{f.finding_id && f.task_id && (
<Button size="sm" variant="ghost" onClick={() => onDeepen(f)}>
<FlaskConicalIcon data-icon="inline-start" />
{t("table.deepen")}
</Button>
)}
{f.finding_id && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="icon"
variant="ghost"
className="size-7 text-muted-foreground hover:text-destructive"
aria-label={t("table.deleteAria")}
>
<Trash2Icon className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("table.deleteConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription className="break-words">
「
<span className="break-all">
{f.name || f.vulnclass || f.summary || `#${f.finding_id}`}
</span>
」 {t("table.deleteConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => onDelete(f)}>{t("common.delete")}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</TableCell>
</TableRow>
{open && (
<TableRow className="hover:bg-transparent">
{/* whitespace-normal 覆盖 TableCell 默认的 nowrap,否则展开区文字
被强制单行、直接溢出单元格。 */}
<TableCell colSpan={COLUMN_COUNT} className="bg-muted/30 whitespace-normal">
<div className="flex flex-col gap-2 px-2 py-1">
{/* 行内编辑:名称/类别/严重等级,可改并保存(仅独立 finding 行)。 */}
{f.finding_id && edit && (
<div className="flex flex-wrap items-end gap-3 rounded-md border bg-background px-3 py-2.5">
<div className="flex min-w-[12rem] flex-1 flex-col gap-1">
<Label className="text-xs text-muted-foreground">{t("table.editName")}</Label>
<Input
value={edit.name}
onChange={(e) => onEditChange((s) => (s ? { ...s, name: e.target.value } : s))}
placeholder={t("table.editNamePlaceholder")}
/>
</div>
<div className="flex min-w-[10rem] flex-col gap-1">
<Label className="text-xs text-muted-foreground">{t("table.editCategory")}</Label>
<Input
value={edit.vulnclass}
onChange={(e) => onEditChange((s) => (s ? { ...s, vulnclass: e.target.value } : s))}
placeholder={t("table.editCategoryPlaceholder")}
/>
</div>
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">{t("table.editSeverity")}</Label>
<Select
value={edit.severity}
onValueChange={(v) => onEditChange((s) => (s ? { ...s, severity: v as Severity } : s))}
>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEVERITIES.map((sv) => (
<SelectItem key={sv} value={sv}>
{tStatus(`severity.${sv}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button size="sm" disabled={saving} onClick={() => onSave(f)}>
{saving ? t("common.saving") : t("common.save")}
</Button>
</div>
)}
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<ShieldAlertIcon className="size-3.5" />
{t("table.evidence")}
{f.vulnclass && (
<span>
{t("table.typeInline")}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">{f.vulnclass}</code>
</span>
)}
{f.param_id && <code className="rounded bg-muted px-1.5 py-0.5 font-mono">{f.param_id}</code>}
{f.assets && f.assets.length > 0 && (
<span className="flex flex-wrap items-center gap-1">
{t("table.assetInline")}
{f.assets.map((a) => (
<code key={a.id} className="rounded bg-muted px-1.5 py-0.5 font-mono" title={a.type}>
{a.label}
</code>
))}
</span>
)}
</div>
<pre className="overflow-x-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap">
{f.evidence}
</pre>
{/* 详细报告(Markdown):展开时按 finding_id 懒加载,免进详情页即可查看。 */}
{f.finding_id && (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span className="flex items-center gap-2">
<FileTextIcon className="size-3.5" />
{t("table.detailReport")}
</span>
{reports[rowKey]?.status === "done" && reports[rowKey]?.text.trim() && (
<CopyButton
text={reports[rowKey]?.text}
successMessage={t("table.reportCopied")}
variant="ghost"
className="h-6 px-2 text-xs"
/>
)}
</div>
{(() => {
const rep = reports[rowKey];
if (!rep || rep.status === "loading")
return <p className="text-xs text-muted-foreground">{t("common.loading")}</p>;
if (rep.status === "error")
return <p className="text-xs text-muted-foreground">{t("table.reportLoadFailed")}</p>;
if (!rep.text.trim())
return <p className="text-xs text-muted-foreground">{t("table.reportEmpty")}</p>;
return (
// break-words 会继承到段落/列表,pre 另加
// whitespace-pre-wrap 让代码块也换行——否则长代码行/长 URL
// 会撑宽 colSpan 单元格,把整张表挤出横向滚动条。
<div className="min-w-0 break-words rounded-md border bg-background px-3 py-2 [&_pre]:whitespace-pre-wrap">
<Markdown text={rep.text} />
</div>
);
})()}
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
{items.length === 0 && (
<TableRow>
<TableCell colSpan={COLUMN_COUNT} className="py-12 text-center text-sm text-muted-foreground">
{t("empty")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
);
}
@@ -0,0 +1,52 @@
"use client";
import * as React from "react";
import { useTranslations } from "next-intl";
import { ExplorationGraph } from "@/components/exploration-graph";
import { api } from "@/lib/api";
import type { Edge, TaskNode } from "@/lib/types";
// FindingLineageView renders the exploration sub-graph from the task's initial
// node down to this finding's node — the same 攻击链路图 canvas as the task graph,
// scoped to just this finding's lineage.
export function FindingLineageView({ findingId }: { findingId: string }) {
const t = useTranslations("explorationGraph");
const [nodes, setNodes] = React.useState<TaskNode[]>([]);
const [edges, setEdges] = React.useState<Edge[]>([]);
const [loaded, setLoaded] = React.useState(false);
React.useEffect(() => {
let alive = true;
api
.findingLineage(findingId)
.then((g) => {
if (!alive) return;
setNodes(g.nodes ?? []);
setEdges(g.edges ?? []);
})
.catch(() => {
/* 조회 실패는 무시한다 (finally 에서 로딩 상태 해제) */
})
.finally(() => {
if (alive) setLoaded(true);
});
return () => {
alive = false;
};
}, [findingId]);
if (loaded && nodes.length === 0) {
return <p className="text-muted-foreground p-6 text-sm">{t("lineage.empty")}</p>;
}
return (
<ExplorationGraph
nodes={nodes}
edges={edges}
className="h-[68vh]"
emptyHint={loaded ? t("lineage.emptyHint") : t("lineage.loading")}
/>
);
}
@@ -0,0 +1,348 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { ArrowLeftIcon, ArrowUpRightIcon, ShieldAlertIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { CopyButton } from "@/components/copy-button";
import { FindingRetestPanel } from "@/components/finding-retest-panel";
import { FindingTrafficPanel } from "@/components/finding-traffic-panel";
import { Markdown } from "@/components/markdown";
import { StatusBadge } from "@/components/status-badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api";
import type { Finding, FindingStatus, Severity } from "@/lib/types";
import { FindingLineageView } from "./lineage";
const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"];
const FINDING_STATUSES: FindingStatus[] = [
"pending",
"in_progress",
"confirmed",
"resolved",
"fixed",
"false_positive",
"ignored",
"duplicate",
"risk_accepted",
];
function fmtTime(ts: string) {
return new Date(ts).toLocaleString("ko-KR");
}
// FieldRow is one label/value line in the right-hand status panel.
function FieldRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-start justify-between gap-3 py-2.5">
<span className="shrink-0 pt-0.5 text-xs text-muted-foreground">{label}</span>
<div className="flex min-w-0 flex-col items-end gap-1 text-right text-sm">{children}</div>
</div>
);
}
function FindingDetailInner() {
const searchParams = useSearchParams();
const id = searchParams.get("id") ?? "";
const contextTaskId = searchParams.get("context_task") ?? "";
const [finding, setFinding] = React.useState<Finding | null>(null);
const [loaded, setLoaded] = React.useState(false);
const [tab, setTab] = React.useState("overview");
const t = useTranslations("findings");
const tStatus = useTranslations("status");
const load = React.useCallback(() => {
if (!id) {
setLoaded(true);
return;
}
api
.getFinding(id, contextTaskId || undefined)
.then((f) => setFinding(f))
.catch(() => setFinding(null))
.finally(() => setLoaded(true));
}, [contextTaskId, id]);
React.useEffect(() => {
load();
}, [load]);
const changeSeverity = React.useCallback(
async (next: Severity) => {
if (!finding || finding.inherited || next === finding.severity) return;
const prev = finding.severity;
setFinding({ ...finding, severity: next });
try {
const updated = await api.setFindingSeverity(id, next);
setFinding(updated);
toast.success(t("detail.severityChanged", { label: tStatus(`severity.${next}`) }));
} catch (e) {
setFinding((cur) => (cur ? { ...cur, severity: prev } : cur));
toast.error(t("toast.updateFailed", { error: (e as Error).message }));
}
},
[finding, id, t, tStatus],
);
const changeStatus = React.useCallback(
async (next: FindingStatus) => {
if (!finding || finding.inherited || next === finding.status) return;
const prev = finding.status;
setFinding({ ...finding, status: next });
try {
const updated = await api.setFindingStatus(id, next);
setFinding(updated);
toast.success(t("detail.statusChanged", { label: tStatus(`finding.${next}`) }));
} catch (e) {
setFinding((cur) => (cur ? { ...cur, status: prev } : cur));
toast.error(t("toast.updateFailed", { error: (e as Error).message }));
}
},
[finding, id, t, tStatus],
);
if (!finding) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-10 text-center">
<p className="text-muted-foreground">{loaded ? t("detail.notFound", { id }) : t("common.loading")}</p>
{loaded && (
<Button asChild variant="outline">
<Link href="/function/findings">
<ArrowLeftIcon /> {t("detail.back")}
</Link>
</Button>
)}
</div>
);
}
const title = finding.name || finding.vulnclass || t("table.unclassified");
return (
<Tabs value={tab} onValueChange={setTab} className="flex flex-1 flex-col gap-0">
{/* Sticky header */}
<header className="sticky top-0 z-10 flex flex-col gap-2 border-b bg-background/95 px-4 py-2.5 backdrop-blur lg:px-6">
<div className="flex flex-wrap items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Button asChild variant="ghost" size="icon" className="size-7">
<Link href="/function/findings">
<ArrowLeftIcon />
</Link>
</Button>
<ShieldAlertIcon className="size-4 text-muted-foreground" />
<h1 className="max-w-md truncate text-sm font-semibold" title={title}>
{title}
</h1>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">#{finding.id}</code>
<Separator orientation="vertical" className="mx-1 h-4" />
<StatusBadge domain="severity" value={finding.severity} dot />
<StatusBadge domain="finding" value={finding.status} dot />
{finding.inherited && finding.source_task_id && (
<Badge variant="outline">{t("detail.sourceTask", { id: finding.source_task_id })}</Badge>
)}
</div>
<TabsList>
<TabsTrigger value="overview">{t("detail.tab.overview")}</TabsTrigger>
<TabsTrigger value="lineage">{t("detail.tab.lineage")}</TabsTrigger>
</TabsList>
</header>
{/* Tab content */}
<div className="flex-1 p-4 lg:p-6">
{/* 概览:左(摘要 + 证据)/ 右(状态区) */}
<TabsContent value="overview" className="mt-0">
<div className="grid gap-4 lg:grid-cols-3">
{/* 左栏 */}
<div className="flex flex-col gap-4 lg:col-span-2">
<Card>
<CardHeader>
<CardTitle className="text-sm">{t("detail.summary")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{finding.summary || t("detail.summaryEmpty")}
</p>
</CardContent>
</Card>
<FindingRetestPanel key={id} findingId={id} readOnly={finding.inherited} onCompleted={load} />
<Card>
<CardHeader>
<CardTitle className="text-sm">{t("detail.evidence")}</CardTitle>
</CardHeader>
<CardContent>
{finding.evidence ? (
<pre className="max-h-[46vh] overflow-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap">
{finding.evidence}
</pre>
) : (
<p className="text-sm text-muted-foreground">{t("detail.evidenceEmpty")}</p>
)}
</CardContent>
</Card>
<FindingTrafficPanel
key={id}
findingId={id}
contextTask={contextTaskId || undefined}
readOnly={finding.inherited}
onChanged={load}
/>
{/* 证据下方:详细报告(Markdown 渲染) */}
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle className="text-sm">{t("table.detailReport")}</CardTitle>
{finding.report && <CopyButton text={finding.report} successMessage={t("table.reportCopied")} />}
</CardHeader>
<CardContent>
{finding.report_stale ? (
<Alert>
<AlertDescription>{t("detail.reportStale")}</AlertDescription>
</Alert>
) : null}
{finding.report ? (
<Markdown text={finding.report} />
) : (
<p className="text-sm text-muted-foreground">{t("table.reportEmpty")}</p>
)}
</CardContent>
</Card>
</div>
{/* 右栏:状态区 */}
<Card className="h-fit lg:sticky lg:top-24">
<CardHeader>
<CardTitle className="text-sm">{t("detail.statusTitle")}</CardTitle>
</CardHeader>
<CardContent className="divide-y">
{/* 漏洞 ID */}
<FieldRow label={t("detail.fieldId")}>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
#{finding.id}
</code>
</FieldRow>
{/* 严重等级 */}
<FieldRow label={t("detail.fieldSeverity")}>
{finding.inherited ? (
<StatusBadge domain="severity" value={finding.severity} dot />
) : (
<Select value={finding.severity} onValueChange={(v) => changeSeverity(v as Severity)}>
<SelectTrigger size="sm" className="h-7 w-auto border-none px-1 shadow-none focus-visible:ring-0">
<StatusBadge domain="severity" value={finding.severity} dot />
</SelectTrigger>
<SelectContent position="popper" align="end">
<SelectGroup>
{SEVERITIES.map((sv) => (
<SelectItem key={sv} value={sv}>
{tStatus(`severity.${sv}`)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)}
</FieldRow>
{/* 处理状态 */}
<FieldRow label={t("detail.fieldStatus")}>
{finding.inherited ? (
<StatusBadge domain="finding" value={finding.status} dot />
) : (
<Select value={finding.status} onValueChange={(v) => changeStatus(v as FindingStatus)}>
<SelectTrigger size="sm" className="h-7 w-auto border-none px-1 shadow-none focus-visible:ring-0">
<StatusBadge domain="finding" value={finding.status} dot />
</SelectTrigger>
<SelectContent position="popper" align="end">
<SelectGroup>
{FINDING_STATUSES.map((st) => (
<SelectItem key={st} value={st}>
{tStatus(`finding.${st}`)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)}
</FieldRow>
{/* 漏洞类型 */}
<FieldRow label={t("detail.fieldType")}>
{finding.vulnclass ? (
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{finding.vulnclass}</code>
) : (
<span className="text-muted-foreground">—</span>
)}
</FieldRow>
{/* 涉及资产 */}
<FieldRow label={t("detail.fieldAssets")}>
{finding.assets && finding.assets.length > 0 ? (
<div className="flex flex-wrap justify-end gap-1">
{finding.assets.map((a) => (
<code
key={a.id}
className="max-w-[16rem] truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
title={`${a.type} · ${a.label}`}
>
{a.label}
</code>
))}
</div>
) : (
<span className="text-muted-foreground">—</span>
)}
</FieldRow>
{/* 所属任务 */}
<FieldRow label={t("detail.fieldTask")}>
{finding.task_id ? (
<Link
href={`/function/tasks/detail?id=${finding.task_id}`}
className="inline-flex max-w-[16rem] items-center gap-1 text-primary hover:underline"
title={finding.task_description}
>
<span className="truncate">{finding.task_description || `#${finding.task_id}`}</span>
<ArrowUpRightIcon className="size-3 shrink-0" />
</Link>
) : (
<span className="text-muted-foreground">{t("detail.taskDeleted")}</span>
)}
</FieldRow>
{/* 发现时间 */}
<FieldRow label={t("detail.fieldTime")}>
<span className="tabular-nums">{fmtTime(finding.ts)}</span>
</FieldRow>
</CardContent>
</Card>
</div>
</TabsContent>
{/* 链路图:从任务初始节点回溯到本漏洞节点的攻击链路 */}
<TabsContent value="lineage" className="mt-0">
<FindingLineageView findingId={finding.id} />
</TabsContent>
</div>
</Tabs>
);
}
// useSearchParams must sit under a Suspense boundary for static export.
export default function FindingDetailPage() {
return (
<React.Suspense fallback={null}>
<FindingDetailInner />
</React.Suspense>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,602 @@
"use client";
import * as React from "react";
import {
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
CopyIcon,
Loader2Icon,
RadioIcon,
SearchIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api } from "@/lib/api";
import type { LLMRecordDetail, LLMRecordItem, LLMTask } from "@/lib/types";
import { cn } from "@/lib/utils";
function fmtTime(ts: string) {
return new Date(ts).toLocaleString("ko-KR", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function fmtLatency(ms: number) {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function fmtTokens(n: number) {
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
}
function tryFormatJSON(s: string): string {
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch {
return s;
}
}
// 현재 상자 안의 텍스트를 복사하는 작은 버튼. 복사에 성공하면 잠시 체크 표시를 보여 준다. text 가 비어 있거나 자리표시자뿐이면 비활성화한다.
function CopyButton({ text }: { text: string }) {
const t = useTranslations("llmRecords");
const [copied, setCopied] = React.useState(false);
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[],
);
const disabled = !text;
const copy = async () => {
if (disabled) return;
try {
await navigator.clipboard.writeText(text);
} catch {
// navigator.clipboard 는 안전하지 않은 컨텍스트(예: http 사설망)에서는 쓸 수 없어 execCommand 로 폴백한다.
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try {
document.execCommand("copy");
} catch {
/* 무시: 지원하지 않으면 조용히 넘어간다 */
}
document.body.removeChild(ta);
}
setCopied(true);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 1500);
};
return (
<Button
variant="ghost"
size="icon"
className="size-5 shrink-0"
disabled={disabled}
title={copied ? t("copyCopied") : t("copyTooltip")}
onClick={copy}
>
{copied ? <CheckIcon className="size-3 text-emerald-600" /> : <CopyIcon className="size-3" />}
</Button>
);
}
const PAGE_SIZES = [25, 50, 100];
export default function LLMRecordsPage() {
const t = useTranslations("llmRecords");
const tp = useTranslations("pagination");
const [page, setPage] = React.useState(0);
const [size, setSize] = React.useState(50);
const [session, setSession] = React.useState("");
const [sessionQ, setSessionQ] = React.useState("");
const [model, setModel] = React.useState("");
const [records, setRecords] = React.useState<LLMRecordItem[]>([]);
const [total, setTotal] = React.useState(0);
const [loading, setLoading] = React.useState(false);
// Recording on/off toggle (settings.llm_record; default off). When off the
// backend records nothing.
const [recEnabled, setRecEnabled] = React.useState(false);
const [recBusy, setRecBusy] = React.useState(false);
// Inline detail panel (Burp-style split, not a dialog)
const [selected, setSelected] = React.useState<LLMRecordItem | null>(null);
const [detail, setDetail] = React.useState<LLMRecordDetail | null>(null);
const [detailLoading, setDetailLoading] = React.useState(false);
// 정규화 뷰 / HTTP 원문 뷰. 원문은 provider 쪽 문제를 진단하는 유일한 근거다: 정규화 뷰는
// 도구 schema 를 담지 않고, 응답에도 tool_use 블록이 없다.
const [rawView, setRawView] = React.useState(false);
const hasRaw = !!(detail?.raw_request || detail?.raw_response);
// 토글은 사용자 선택을 유지하되, 원문이 없는 옛 기록으로 전환하면 빈 화면 대신 자동으로 파싱 뷰로 되돌린다.
const showRaw = rawView && hasRaw;
// 원문 요청 본문은 JSON 이라 pretty-print 는 배치만 바꾸고 의미는 바꾸지 않아 읽기 편하다. 원문 응답은 SSE
// 프레임이고, tryFormatJSON 은 파싱에 실패하면 원본을 그대로 돌려주므로 양쪽이 한 함수를 함께 쓰면 된다.
const reqText = showRaw
? detail?.raw_request && tryFormatJSON(detail.raw_request)
: detail?.request_body && tryFormatJSON(detail.request_body);
const respText = showRaw ? detail?.raw_response : detail?.response_body && tryFormatJSON(detail.response_body);
// Per-task delete (task picker + confirm dialog)
const [tasks, setTasks] = React.useState<LLMTask[]>([]);
const [pickedTask, setPickedTask] = React.useState("");
const [deleteOpen, setDeleteOpen] = React.useState(false);
const [deleting, setDeleting] = React.useState(false);
const [reloadTick, setReloadTick] = React.useState(0); // manual refetch trigger
// Load recording toggle state on mount.
React.useEffect(() => {
let alive = true;
api
.settings()
.then((s) => {
if (alive) setRecEnabled(!!s.llm_record);
})
.catch(() => {
/* 조회 실패는 무시하고 기본 상태를 유지한다 */
});
return () => {
alive = false;
};
}, []);
const toggleRecording = async (on: boolean) => {
setRecBusy(true);
setRecEnabled(on); // optimistic
try {
const s = await api.setSettings({ llm_record: on });
setRecEnabled(!!s.llm_record);
} catch {
setRecEnabled(!on); // revert on failure
} finally {
setRecBusy(false);
}
};
// Debounce session filter.
React.useEffect(() => {
const timer = setTimeout(() => setSessionQ(session.trim()), 300);
return () => clearTimeout(timer);
}, [session]);
// Reset page on filter change.
// biome-ignore lint/correctness/useExhaustiveDependencies: these values intentionally trigger a page reset.
React.useEffect(() => {
setPage(0);
}, [sessionQ, model, size, pickedTask]);
// Load list.
// biome-ignore lint/correctness/useExhaustiveDependencies: reloadTick is an explicit manual-refetch trigger.
React.useEffect(() => {
let alive = true;
setLoading(true);
api
.llmRecords({
model: model || undefined,
session: sessionQ || undefined,
task: pickedTask || undefined,
page,
size,
})
.then((r) => {
if (!alive) return;
setRecords(r.records ?? []);
setTotal(r.total ?? 0);
})
.catch(() => {
/* 조회 실패는 무시한다 (finally 에서 로딩 상태 해제) */
})
.finally(() => alive && setLoading(false));
api
.llmTasks()
.then((r) => {
if (alive) setTasks(r.tasks ?? []);
})
.catch(() => {
/* 조회 실패는 무시한다 */
});
return () => {
alive = false;
};
}, [page, size, sessionQ, model, pickedTask, reloadTick]);
// Delete every LLM record for the picked task, then refetch.
const confirmDelete = () => {
setDeleting(true);
api
.llmRecordsDeleteTask(pickedTask)
.then(() => {
setDeleteOpen(false);
setSelected(null);
setDetail(null);
setPickedTask("");
setPage(0);
setReloadTick((n) => n + 1);
})
.catch(() => {
/* 삭제 후 재조회 실패는 무시한다 (finally 에서 삭제 상태 해제) */
})
.finally(() => setDeleting(false));
};
// Lazy-load full request/response when a row is selected.
React.useEffect(() => {
if (!selected) {
setDetail(null);
return;
}
let alive = true;
setDetailLoading(true);
setDetail(null);
api
.llmRecordDetail(selected.id)
.then((d) => {
if (alive) setDetail(d);
})
.catch(() => {
/* 상세 조회 실패는 무시한다 (finally 에서 로딩 상태 해제) */
})
.finally(() => {
if (alive) setDetailLoading(false);
});
return () => {
alive = false;
};
}, [selected]);
const totalPages = Math.max(1, Math.ceil(total / size));
const rangeStart = total === 0 ? 0 : page * size + 1;
const rangeEnd = page * size + records.length;
return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<RadioIcon className="h-5 w-5 text-muted-foreground" />
<h1 className="text-xl font-semibold tracking-tight">{t("title")}</h1>
<Badge variant="secondary">{total}</Badge>
</div>
</div>
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-2">
<div className="relative max-w-sm flex-1">
<SearchIcon className="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={t("searchPlaceholder")}
value={session}
onChange={(e) => setSession(e.target.value)}
className="h-8 pl-8"
/>
</div>
<Input placeholder="Model" className="h-8 w-48" value={model} onChange={(e) => setModel(e.target.value)} />
<Select value={pickedTask} onValueChange={setPickedTask}>
<SelectTrigger size="sm" className="w-56">
<SelectValue placeholder={t("taskPlaceholder")} />
</SelectTrigger>
<SelectContent>
{tasks.length === 0 ? (
<SelectItem value="__none__" disabled>
{t("taskEmpty")}
</SelectItem>
) : (
tasks.map((task) => (
<SelectItem key={task.task_id} value={task.task_id}>
<span className="font-mono">#{task.task_id}</span>
<span className="ml-2 text-muted-foreground">({task.count})</span>
</SelectItem>
))
)}
</SelectContent>
</Select>
<Button
variant="destructive"
size="sm"
className="h-8"
disabled={!pickedTask || deleting}
title={pickedTask ? undefined : t("selectTaskFirst")}
onClick={() => setDeleteOpen(true)}
>
<Trash2Icon className="size-3.5" />
{t("deleteTaskConversations")}
</Button>
<Select value={String(size)} onValueChange={(v) => setSize(Number(v))}>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((n) => (
<SelectItem key={n} value={String(n)}>
{n}
{tp("perPage")}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Recording on/off — off means no LLM calls are recorded */}
<div className="flex items-center gap-2 rounded-md border px-2.5 py-1">
<Switch
id="llm-rec-toggle"
size="sm"
checked={recEnabled}
onCheckedChange={toggleRecording}
disabled={recBusy}
/>
<label
htmlFor="llm-rec-toggle"
className={cn(
"cursor-pointer text-xs font-medium select-none",
recEnabled ? "text-foreground" : "text-muted-foreground",
)}
>
{recEnabled ? t("recording") : t("recordingOff")}
</label>
</div>
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
<span className="tabular-nums">
{rangeStart}–{rangeEnd} / {total}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<ChevronLeftIcon />
</Button>
<span className="tabular-nums">
{page + 1} / {totalPages}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page + 1 >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
>
<ChevronRightIcon />
</Button>
</div>
</div>
{/* History table + inline detail (Burp-style split) */}
<div className="flex h-[calc(100vh-13rem)] min-h-0 flex-col gap-3">
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
<div className="min-h-0 flex-1 overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead className="w-[130px]">{t("colTime")}</TableHead>
<TableHead className="w-[60px]">{t("colTask")}</TableHead>
<TableHead className="w-[90px]">Worker</TableHead>
<TableHead className="w-[100px]">Profile</TableHead>
<TableHead className="w-[140px]">Model</TableHead>
<TableHead className="w-[70px]">{t("colLatency")}</TableHead>
<TableHead className="w-[90px]">Tokens</TableHead>
<TableHead className="w-[60px]">{t("colStatus")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && records.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="py-12 text-center">
<Loader2Icon className="mx-auto h-5 w-5 animate-spin text-muted-foreground" />
</TableCell>
</TableRow>
) : records.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="py-12 text-center text-sm text-muted-foreground">
{t("empty")}
</TableCell>
</TableRow>
) : (
records.map((rec) => (
<TableRow
key={rec.id}
className={cn("cursor-pointer", selected?.id === rec.id && "bg-accent hover:bg-accent")}
onClick={() => setSelected(rec)}
>
<TableCell className="text-xs text-muted-foreground tabular-nums">{fmtTime(rec.ts)}</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground">
{rec.task_id ? `#${rec.task_id}` : "-"}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs font-mono">
{rec.worker || "-"}
</Badge>
</TableCell>
<TableCell>
<span className="text-xs">{rec.profile_name || "-"}</span>
</TableCell>
<TableCell>
<span className="text-xs font-mono">{rec.model || "-"}</span>
</TableCell>
<TableCell>
<span className={cn("text-xs", rec.latency_ms > 30000 && "text-amber-500")}>
{fmtLatency(rec.latency_ms)}
</span>
</TableCell>
<TableCell>
<span className="text-xs">
{fmtTokens(rec.input_tokens)} / {fmtTokens(rec.output_tokens)}
</span>
</TableCell>
<TableCell>
{rec.status === "ok" ? (
<Badge variant="secondary" className="text-xs text-emerald-600">
OK
</Badge>
) : (
<Badge variant="destructive" className="text-xs">
Error
</Badge>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</Card>
{/* Inline detail panel */}
{selected && (
<Card className="flex h-[42%] min-h-0 flex-col overflow-hidden py-0">
{/* Detail header */}
<div className="flex items-center gap-2 border-b px-3 py-2">
<Badge variant="outline" className="text-xs font-mono">
#{selected.id}
</Badge>
<Badge variant="outline" className="text-xs font-mono">
{selected.profile_name || "-"}
</Badge>
<Badge variant="outline" className="text-xs font-mono">
{selected.model || "-"}
</Badge>
{selected.task_id && (
<Badge variant="outline" className="text-xs font-mono">
{t("taskBadge", { id: selected.task_id })}
</Badge>
)}
<span className="text-xs text-muted-foreground">{fmtTime(selected.ts)}</span>
<span className={cn("text-xs", selected.latency_ms > 30000 && "text-amber-500")}>
{fmtLatency(selected.latency_ms)}
</span>
{selected.status === "ok" ? (
<Badge variant="secondary" className="text-xs text-emerald-600">
OK
</Badge>
) : (
<Badge variant="destructive" className="text-xs">
Error
</Badge>
)}
{/* 원문 뷰 토글. 옛 기록은 원문이 없어 이때는 조용히 폴백하지 않고 비활성화해, 「원문과 파싱이 같다」처럼
보이는 것을 막는다. */}
<Button
variant={showRaw ? "secondary" : "ghost"}
size="sm"
className="ml-auto h-7 shrink-0 text-xs"
disabled={!hasRaw}
title={hasRaw ? t("rawTooltipHas") : t("rawTooltipNone")}
onClick={() => setRawView((v) => !v)}
>
{t("raw")}
</Button>
<Button variant="ghost" size="icon" className="size-7 shrink-0" onClick={() => setSelected(null)}>
<XIcon />
</Button>
</div>
{/* Request / Response split */}
<div className="grid min-h-0 flex-1 grid-cols-2 divide-x">
<div className="flex min-h-0 min-w-0 flex-col">
<div className="flex items-center gap-2 border-b py-0.5 pr-1.5 pl-3 text-[11px] font-medium text-muted-foreground">
<span>Request{showRaw && ` · ${t("raw")}`}</span>
<CopyButton text={reqText || ""} />
</div>
<div className="min-h-0 flex-1 overflow-auto">
{detailLoading ? (
<div className="flex items-center gap-2 p-3 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
{t("loading")}
</div>
) : (
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
{reqText || t("emptyContent")}
</pre>
)}
</div>
</div>
<div className="flex min-h-0 min-w-0 flex-col">
<div className="flex items-center gap-2 border-b py-0.5 pr-1.5 pl-3 text-[11px] font-medium text-muted-foreground">
<span>Response{showRaw && ` · ${t("raw")}(SSE)`}</span>
<CopyButton text={respText || ""} />
</div>
<div className="min-h-0 flex-1 overflow-auto">
{detailLoading ? (
<div className="flex items-center gap-2 p-3 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
{t("loading")}
</div>
) : (
<pre
className={cn(
"p-3 font-mono text-xs break-all whitespace-pre-wrap",
selected.status !== "ok" && "text-red-600 dark:text-red-400",
)}
>
{respText || t("emptyContent")}
</pre>
)}
</div>
</div>
</div>
</Card>
)}
</div>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteTitle", { taskId: pickedTask })}</AlertDialogTitle>
<AlertDialogDescription>{t("deleteDesc")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
confirmDelete();
}}
disabled={deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleting ? t("deleting") : t("confirmDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+481
View File
@@ -0,0 +1,481 @@
"use client";
import * as React from "react";
import { AlertCircleIcon, CheckCircle2Icon, DownloadIcon, PlugZapIcon, RefreshCwIcon, SearchIcon } from "lucide-react";
import { useTranslations } from "next-intl";
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: string[] = ["subdomain", "service", "app"];
export default function AssetSyncPage() {
const t = useTranslations("assetSync");
return (
<div className="p-4 md:p-6">
<div className="mb-4">
<h1 className="font-semibold text-xl">{t("title")}</h1>
<p className="text-muted-foreground text-sm">{t("subtitle")}</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 t = useTranslations("assetSync");
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(t("statusLoadFailed", { msg: e.message })))
.finally(() => setLoadingStatus(false));
}, [t]);
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">{t("notReady")}</CardContent>
</Card>
)}
</div>
);
}
// ── 데이터 소스 상태 카드 ─────────────────────────────────────────────────────
function DataSourceCard({
status,
loading,
onChanged,
}: {
status: SSStatus | null;
loading: boolean;
onChanged: () => void;
}) {
const t = useTranslations("assetSync");
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(t("created"));
onChanged();
} catch (e) {
toast.error(t("createFailed", { msg: (e as Error).message }));
} finally {
setBusy(false);
}
};
const save = async () => {
if (!url.trim()) return toast.error(t("urlRequired"));
setBusy(true);
try {
const r = await api.ssDatasource({ url: url.trim(), api_key: apiKey.trim() });
toast.success(r.enabled ? t("savedEnabled") : t("savedPending"));
setApiKey("");
onChanged();
} catch (e) {
toast.error(t("saveFailed", { msg: (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" /> {t("dsTitle")}
<StatusBadge status={status} loading={loading} />
</CardTitle>
<Button variant="ghost" size="sm" onClick={onChanged} disabled={loading}>
<RefreshCwIcon className={loading ? "size-4 animate-spin" : "size-4"} /> {t("refresh")}
</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">{t("notCreatedDesc")}</p>
<Button onClick={create} disabled={busy}>
{t("createDs")}
</Button>
</div>
) : (
<>
{!status.configured && <p className="text-amber-600 text-sm dark:text-amber-500">{t("notConfigured")}</p>}
{status.configured && !status.enabled && (
<p className="text-amber-600 text-sm dark:text-amber-500">{t("notEnabled")}</p>
)}
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1.5">
<Label>{t("mcpUrl")}</Label>
<Input placeholder={t("mcpUrlPlaceholder")} value={url} onChange={(e) => setUrl(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>{t("apiKeyLabel")}</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}>
{t("saveEnable")}
</Button>
{status.enabled && status.tools.length > 0 && (
<span className="text-muted-foreground text-xs">{t("toolsFound", { count: status.tools.length })}</span>
)}
</div>
</>
)}
</CardContent>
</Card>
);
}
function StatusBadge({ status, loading }: { status: SSStatus | null; loading: boolean }) {
const t = useTranslations("assetSync");
if (loading || !status) return <Badge variant="secondary">{t("badgeChecking")}</Badge>;
if (!status.exists) return <Badge variant="destructive">{t("badgeNotCreated")}</Badge>;
if (!status.configured) return <Badge variant="outline">{t("badgeNotConfigured")}</Badge>;
if (!status.enabled) return <Badge variant="outline">{t("badgeNotEnabled")}</Badge>;
if (status.reachable)
return (
<Badge className="bg-emerald-600 hover:bg-emerald-600">
<CheckCircle2Icon className="mr-1 size-3" /> {t("badgeConnected")}
</Badge>
);
return (
<Badge variant="destructive">
<AlertCircleIcon className="mr-1 size-3" /> {t("badgeUnreachable")}
</Badge>
);
}
// ── 동기화 작업 영역(프로젝트/작업 기준) ──────────────────────────────────────
function SyncWorkbench() {
const t = useTranslations("assetSync");
const tp = useTranslations("pagination");
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(t("listLoadFailed", { msg: e.message }))).finally(() => setLoading(false));
}, [dimension, page, search, t]);
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((key) => assetTypes[key]);
const runSync = async () => {
if (selected.size === 0) return toast.error(t("selectAtLeastOneTarget", { dim: dimension }));
if (chosenTypes.length === 0) return toast.error(t("selectAtLeastOneType"));
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(t("syncDone", { total }));
} catch (e) {
toast.error(t("syncFailed", { msg: (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">
{t("loading")}
</TableCell>
</TableRow>
);
}
if (rows.length === 0) {
return (
<TableRow>
<TableCell colSpan={4} className="py-8 text-center text-muted-foreground text-sm">
{t("noData")}
</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((task) => (
<TableRow key={task.id} className="cursor-pointer" onClick={() => toggle(task.name)}>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox checked={selected.has(task.name)} onCheckedChange={() => toggle(task.name)} />
</TableCell>
<TableCell className="font-medium">{task.name}</TableCell>
<TableCell>
<Badge variant={task.progress === 100 ? "secondary" : "outline"}>
{task.progress != null ? `${task.progress}%` : "—"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-xs">{task.endTime || task.creatTime || "—"}</TableCell>
</TableRow>
));
};
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t("workbenchTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 기준 전환 */}
<Tabs
value={dimension}
onValueChange={(v) => {
setDimension(v as Dimension);
setPage(1);
}}
>
<TabsList>
<TabsTrigger value="project">{t("dimProject")}</TabsTrigger>
<TabsTrigger value="task">{t("dimTask")}</TabsTrigger>
</TabsList>
</Tabs>
{/* 자산 유형 + 옵션 */}
<div className="flex flex-wrap items-center gap-4">
<span className="font-medium text-sm">{t("syncAssetsLabel")}</span>
{ASSET_TYPES.map((key) => (
<label key={key} htmlFor={`at-${key}`} className="flex items-center gap-1.5 text-sm">
<Checkbox
id={`at-${key}`}
checked={!!assetTypes[key]}
onCheckedChange={(c) => setAssetTypes((prev) => ({ ...prev, [key]: !!c }))}
/>
{t(`assetType.${key}`)}
</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)} />
{t("createCompany")}
</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" ? t("searchProject") : t("searchTask")}
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">{t("selectedCount", { count: selected.size })}</span>
<Button onClick={runSync} disabled={syncing || selected.size === 0}>
<DownloadIcon className={syncing ? "size-4 animate-pulse" : "size-4"} /> {t("syncSelected")}
</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" ? t("colProjectName") : t("colTaskName")}</TableHead>
{dimension === "project" ? (
<>
<TableHead>{t("colTag")}</TableHead>
<TableHead className="text-right">{t("colAssetCount")}</TableHead>
</>
) : (
<>
<TableHead>{t("colStatus")}</TableHead>
<TableHead>{t("colTime")}</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)}>
{tp("prev")}
</Button>
<span className="text-muted-foreground text-xs">{t("pageNo", { page })}</span>
<Button
variant="outline"
size="sm"
disabled={rows.length < 50 || loading}
onClick={() => setPage((p) => p + 1)}
>
{tp("next")}
</Button>
</div>
{/* 결과 */}
{result && <SyncResult result={result} />}
</CardContent>
</Card>
);
}
function SyncResult({ result }: { result: Awaited<ReturnType<typeof api.ssSync>> }) {
const t = useTranslations("assetSync");
const synced = result.synced ?? {};
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">
{t.has(`assetType.${k}`) ? t(`assetType.${k}`) : k}: {v}
</Badge>
))}
</div>
{result.companies && result.companies.length > 0 && (
<p className="text-muted-foreground">{t("resultCompanies", { names: 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>{t("moreErrors", { count: result.errors.length })}</li>}
</ul>
)}
</div>
);
}
@@ -0,0 +1,757 @@
"use client";
import * as React from "react";
import {
ChevronLeftIcon,
ChevronRightIcon,
GlobeIcon,
KeyRoundIcon,
LayoutTemplateIcon,
LinkIcon,
type LucideIcon,
NetworkIcon,
PlusIcon,
SmartphoneIcon,
Trash2Icon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { AssetDslSearch } from "@/components/asset-dsl-search";
import { ScopeTextEditor } from "@/components/scope-text-editor";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Spinner } from "@/components/ui/spinner";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { api } from "@/lib/api";
import { parseCompanyScopeText } from "@/lib/company-scope";
import type { Asset, NewAssetType } from "@/lib/types";
import { cn } from "@/lib/utils";
const PAGE_SIZES = [25, 50, 100, 200];
const METHOD_COLOR: Record<string, string> = {
DELETE: "bg-red-100 text-red-700",
GET: "bg-emerald-100 text-emerald-700",
HEAD: "bg-purple-100 text-purple-700",
OPTIONS: "bg-slate-100 text-slate-600",
PATCH: "bg-orange-100 text-orange-700",
POST: "bg-blue-100 text-blue-700",
PUT: "bg-amber-100 text-amber-700",
};
// Node-type labels are translated at render via `t(`kind.${key}`)` because this
// is a module constant (no hook access here).
const TABS: { key: NewAssetType; icon: LucideIcon }[] = [
{ key: "root_domain", icon: GlobeIcon },
{ key: "ip", icon: NetworkIcon },
{ key: "subdomain", icon: GlobeIcon },
{ key: "app", icon: SmartphoneIcon },
{ key: "service", icon: LayoutTemplateIcon },
{ key: "endpoint", icon: LinkIcon },
];
function firstText(values: Array<string | undefined>, fallback: string): string {
return values.find((value) => value?.trim()) ?? fallback;
}
function assetLabel(asset: Asset): string {
switch (asset.type) {
case "root_domain":
case "subdomain":
return firstText([asset.domain], `#${asset.id}`);
case "ip":
return firstText([asset.ip], `#${asset.id}`);
case "app":
return firstText([asset.app_name, asset.url], `#${asset.id}`);
case "service": {
const host = firstText([asset.ip, asset.domain], "");
const address = [host, asset.port].filter((value) => value !== undefined && value !== "").join(":");
return firstText([asset.url, address, asset.service_name], `#${asset.id}`);
}
case "endpoint":
return firstText([[asset.method, asset.url].filter(Boolean).join(" ")], `#${asset.id}`);
}
}
function MethodBadge({ method }: { method: string }) {
const normalized = method.toUpperCase();
return (
<span
className={cn(
"inline-block rounded px-1.5 py-0.5 font-mono font-semibold text-[10px] leading-none",
METHOD_COLOR[normalized] ?? "bg-muted text-muted-foreground",
)}
>
{normalized || "—"}
</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(value?: number | null) {
if (!value || !Number.isFinite(value) || value <= 0) return "—";
const units = ["B", "KB", "MB"];
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
return `${index === 0 ? value : (value / 1024 ** index).toFixed(1)} ${units[index]}`;
}
function Chips({ items, mono }: { items: string[]; mono?: boolean }) {
const clean = items.filter(Boolean);
if (clean.length === 0) return <span className="text-muted-foreground text-xs">—</span>;
return (
<div className="flex flex-wrap gap-1">
{clean.map((item) => (
<Badge key={item} variant="outline" className={cn(mono && "font-mono")}>
{item}
</Badge>
))}
</div>
);
}
function SourceCell({ asset }: { asset: Asset }) {
const t = useTranslations("taskDetail.assetsTab");
const source = firstText([asset.task_source], "legacy");
const sourceLabel = t.has(`source.label.${source}`) ? t(`source.label.${source}`) : source;
const summary = firstText([asset.task_source_summary], t("source.legacySummary"));
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="max-w-28 shrink-0 font-normal">
<span className="truncate">{sourceLabel}</span>
</Badge>
</TooltipTrigger>
<TooltipContent side="left" align="start" className="max-w-sm">
<div className="flex min-w-0 flex-col gap-1">
<span className="font-medium">{sourceLabel}</span>
<span className="[overflow-wrap:anywhere]">{summary}</span>
{asset.task_source_node_id ? (
<span className="font-mono opacity-80">{t("source.node", { id: asset.task_source_node_id })}</span>
) : null}
</div>
</TooltipContent>
</Tooltip>
);
}
function AssetCard({
children,
cols,
loaded,
onPage,
onSize,
page,
size,
total,
}: {
children: React.ReactNode;
cols: string[];
loaded: boolean;
onPage: (page: number) => void;
onSize: (size: number) => void;
page: number;
size: number;
total: number;
}) {
const t = useTranslations("taskDetail.assetsTab");
const tp = useTranslations("pagination");
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 = Math.min(total, page * size + rows.length);
let tableRows: React.ReactNode;
if (!loaded) {
tableRows = (
<TableRow>
<TableCell colSpan={cols.length} className="py-14 text-center">
<Spinner className="mx-auto" />
</TableCell>
</TableRow>
);
} else if (rows.length > 0) {
tableRows = rows;
} else {
tableRows = (
<TableRow>
<TableCell colSpan={cols.length} className="py-10 text-center text-muted-foreground text-sm">
{t("empty")}
</TableCell>
</TableRow>
);
}
return (
<Card className="flex min-h-0 flex-1 flex-col py-0">
<CardContent className="scrollbar-thin scrollbar-track-transparent min-h-0 flex-1 overflow-auto p-0">
<Table>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow>
{cols.map((column) => (
<TableHead key={column}>{column}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>{tableRows}</TableBody>
</Table>
</CardContent>
{total > 0 ? (
<CardFooter className="gap-2 px-3 py-1.5 text-muted-foreground text-xs">
<Select
value={String(size)}
onValueChange={(value) => {
onSize(Number(value));
onPage(0);
}}
>
<SelectTrigger size="sm" className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{PAGE_SIZES.map((value) => (
<SelectItem key={value} value={String(value)}>
{value}
{tp("perPage")}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<span className="tabular-nums">{tp("range", { from: start, to: end, total })}</span>
{pageCount > 1 ? (
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="icon-sm"
disabled={page <= 0}
onClick={() => onPage(Math.max(0, page - 1))}
aria-label={tp("prev")}
>
<ChevronLeftIcon />
</Button>
<span className="tabular-nums">
{page + 1} / {pageCount}
</span>
<Button
variant="outline"
size="icon-sm"
disabled={page + 1 >= pageCount}
onClick={() => onPage(Math.min(pageCount - 1, page + 1))}
aria-label={tp("next")}
>
<ChevronRightIcon />
</Button>
</div>
) : null}
</CardFooter>
) : null}
</Card>
);
}
function AddTaskAssetsSheet({
onAttached,
onOpenChange,
open,
taskId,
}: {
onAttached: () => void;
onOpenChange: (open: boolean) => void;
open: boolean;
taskId: string;
}) {
const t = useTranslations("taskDetail.assetsTab");
const [scopeText, setScopeText] = React.useState("");
const [saving, setSaving] = React.useState(false);
const parsedScope = React.useMemo(() => parseCompanyScopeText(scopeText), [scopeText]);
React.useEffect(() => {
if (!open) return;
setScopeText("");
}, [open]);
const attach = async () => {
if (parsedScope.rules.length === 0 || parsedScope.errors.length > 0) return;
setSaving(true);
try {
const result = await api.registerTaskAssetScopes(taskId, parsedScope.rules);
const assetSummary = result.assets_linked + result.assets_existing;
toast.success(t("addSheet.success", { requested: result.requested, assetSummary }));
onAttached();
onOpenChange(false);
} catch (reason) {
toast.error(t("addSheet.error", { message: String((reason as Error)?.message ?? reason) }));
} finally {
setSaving(false);
}
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-xl">
<SheetHeader>
<SheetTitle>{t("addSheet.title")}</SheetTitle>
<SheetDescription>{t("addSheet.description")}</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
<ScopeTextEditor
id="task-asset-scope"
value={scopeText}
onValueChange={setScopeText}
parsed={parsedScope}
label={t("addSheet.editorLabel")}
description={t("addSheet.editorDescription")}
/>
</div>
<SheetFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{t("addSheet.cancel")}
</Button>
<Button
onClick={() => void attach()}
disabled={saving || parsedScope.rules.length === 0 || parsedScope.errors.length > 0}
>
{saving ? <Spinner data-icon="inline-start" /> : <PlusIcon data-icon="inline-start" />}
{parsedScope.rules.length > 0
? t("addSheet.submitN", { count: parsedScope.rules.length })
: t("addSheet.submit")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
export function AssetsTab({ taskId }: { taskId: string }) {
const t = useTranslations("taskDetail.assetsTab");
const [rows, setRows] = React.useState<Asset[]>([]);
const [total, setTotal] = React.useState(0);
const [counts, setCounts] = React.useState<Record<string, number>>({});
const [loaded, setLoaded] = React.useState(false);
const [tab, setTab] = React.useState<NewAssetType>("root_domain");
const [page, setPage] = React.useState(0);
const [size, setSize] = React.useState(50);
const [query, setQuery] = React.useState("");
const [loading, setLoading] = React.useState(false);
const [dslError, setDslError] = React.useState("");
const [refreshKey, setRefreshKey] = React.useState(0);
const [addOpen, setAddOpen] = React.useState(false);
const [removeTarget, setRemoveTarget] = React.useState<Asset | null>(null);
const [removing, setRemoving] = React.useState(false);
const assetsRequestRef = React.useRef(0);
const dslMode = query.trim() !== "";
React.useEffect(() => {
setPage(0);
setRows([]);
setLoaded(false);
}, []);
// Switching type tabs resets the search (DSL fields differ per type; matches
// the global asset view).
// biome-ignore lint/correctness/useExhaustiveDependencies: tab changes intentionally reset tab-local controls.
React.useEffect(() => {
setQuery("");
setDslError("");
}, [tab]);
// Query / tab / page-size changes restart server pagination.
// biome-ignore lint/correctness/useExhaustiveDependencies: these controls intentionally reset server pagination.
React.useEffect(() => setPage(0), [tab, size, query]);
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey is an explicit manual-reload trigger.
React.useEffect(() => {
let active = true;
const dsl = query.trim();
const request = ++assetsRequestRef.current;
const load = async () => {
setLoading(true);
try {
// The search always stays within this task's assets: taskAssets and
// searchTaskAssets both pin task_id server-side.
const [current, nextCounts] = await Promise.all([
dsl
? api.searchTaskAssets(taskId, dsl, tab, size, page * size)
: api.taskAssets(taskId, tab, size, page * size),
api.assetCounts(taskId),
]);
if (!active || assetsRequestRef.current !== request) return;
setRows(current.assets);
setTotal(current.total);
setCounts(nextCounts ?? {});
setDslError("");
} catch (reason) {
if (!active || assetsRequestRef.current !== request) return;
const message = String((reason as Error)?.message ?? reason);
if (dsl) {
setDslError(message);
setRows([]);
setTotal(0);
} else {
toast.error(t("loadError", { message }));
}
} finally {
if (active && assetsRequestRef.current === request) {
setLoading(false);
setLoaded(true);
}
}
};
// Debounce DSL keystrokes; plain (re)loads and polling run immediately.
const debounce = setTimeout(() => void load(), dsl ? 400 : 0);
const timer = setInterval(() => void load(), 10_000);
return () => {
active = false;
clearTimeout(debounce);
clearInterval(timer);
};
}, [page, query, refreshKey, size, tab, taskId]);
React.useEffect(() => {
const maxPage = Math.max(0, Math.ceil(total / size) - 1);
if (page > maxPage) setPage(maxPage);
}, [page, size, total]);
const refresh = React.useCallback(() => {
setLoaded(false);
setRefreshKey((current) => current + 1);
}, []);
const remove = async () => {
if (!removeTarget) return;
setRemoving(true);
try {
await api.detachTaskAsset(taskId, removeTarget.id);
toast.success(t("remove.success", { label: assetLabel(removeTarget) }));
setRemoveTarget(null);
refresh();
} catch (reason) {
toast.error(t("remove.error", { message: String((reason as Error)?.message ?? reason) }));
} finally {
setRemoving(false);
}
};
const removeButton = (asset: Asset) => (
<Button
variant="ghost"
size="icon-sm"
onClick={() => setRemoveTarget(asset)}
aria-label={t("remove.aria", { label: assetLabel(asset) })}
title={t("remove.title")}
>
<Trash2Icon />
</Button>
);
const commonCardProps = { loaded, onPage: setPage, onSize: setSize, page, size, total };
const totalAll = TABS.reduce((sum, item) => sum + (counts[item.key] ?? 0), 0);
const searchBox = (
<AssetDslSearch
query={query}
onChange={setQuery}
loading={loading}
error={dslError}
count={dslMode ? total : undefined}
/>
);
return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<h2 className="font-medium text-sm">{t("title")}</h2>
<p className="text-muted-foreground text-xs">{t("totalAll", { count: totalAll })}</p>
</div>
<Button size="sm" onClick={() => setAddOpen(true)}>
<PlusIcon data-icon="inline-start" />
{t("add")}
</Button>
</div>
<Tabs
value={tab}
onValueChange={(value) => setTab(value as NewAssetType)}
className="flex min-h-0 flex-1 flex-col gap-2"
>
<div className="overflow-x-auto overflow-y-hidden">
<TabsList className="w-max">
{TABS.map((item) => (
<TabsTrigger key={item.key} value={item.key}>
<item.icon data-icon="inline-start" />
{t(`kind.${item.key}`)}
<span className="text-muted-foreground tabular-nums">{counts[item.key] ?? 0}</span>
</TabsTrigger>
))}
</TabsList>
</div>
{searchBox}
<TabsContent value="root_domain" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard cols={[t("col.domain"), t("col.icp"), t("col.source"), t("col.action")]} {...commonCardProps}>
{rows.map((asset) => (
<TableRow key={asset.id}>
<TableCell className="font-medium font-mono text-xs">{asset.domain}</TableCell>
<TableCell className="text-xs">{asset.icp || "—"}</TableCell>
<TableCell>
<SourceCell asset={asset} />
</TableCell>
<TableCell className="w-16">{removeButton(asset)}</TableCell>
</TableRow>
))}
</AssetCard>
</TabsContent>
<TabsContent value="ip" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard
cols={[
"IP",
t("col.cSegment"),
t("col.boundDomains"),
t("col.openPorts"),
t("col.source"),
t("col.action"),
]}
{...commonCardProps}
>
{rows.map((asset) => (
<TableRow key={asset.id}>
<TableCell className="font-medium font-mono text-xs">{asset.ip}</TableCell>
<TableCell className="font-mono text-xs">{asset.c_segment || "—"}</TableCell>
<TableCell>
<Chips items={asset.bound_domains ?? []} mono />
</TableCell>
<TableCell>
<Chips
items={(asset.open_ports ?? []).map((item) =>
item.service ? `${item.port}/${item.service}` : String(item.port),
)}
mono
/>
</TableCell>
<TableCell>
<SourceCell asset={asset} />
</TableCell>
<TableCell className="w-16">{removeButton(asset)}</TableCell>
</TableRow>
))}
</AssetCard>
</TabsContent>
<TabsContent value="subdomain" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard
cols={[
t("col.domain"),
t("col.rootDomain"),
t("col.recordType"),
t("col.recordValue"),
t("col.source"),
t("col.action"),
]}
{...commonCardProps}
>
{rows.map((asset) => (
<TableRow key={asset.id}>
<TableCell className="font-medium font-mono text-xs">{asset.domain}</TableCell>
<TableCell className="font-mono text-xs">{asset.root_domain || "—"}</TableCell>
<TableCell className="text-xs">{asset.record_type || "—"}</TableCell>
<TableCell className="max-w-xs truncate font-mono text-xs">
{(Array.isArray(asset.record_value) ? asset.record_value.join(", ") : asset.record_value) || "—"}
</TableCell>
<TableCell>
<SourceCell asset={asset} />
</TableCell>
<TableCell className="w-16">{removeButton(asset)}</TableCell>
</TableRow>
))}
</AssetCard>
</TabsContent>
<TabsContent value="app" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard
cols={[
t("col.app"),
t("col.address"),
t("col.category"),
t("col.title"),
t("col.fingerprint"),
t("col.source"),
t("col.action"),
]}
{...commonCardProps}
>
{rows.map((asset) => (
<TableRow key={asset.id}>
<TableCell className="max-w-48 truncate font-medium text-xs">{asset.app_name || "—"}</TableCell>
<TableCell className="max-w-xs truncate font-mono text-xs" title={asset.url}>
{asset.url || "—"}
</TableCell>
<TableCell className="text-xs">{asset.category || "—"}</TableCell>
<TableCell className="max-w-48 truncate text-xs">{asset.page_title || "—"}</TableCell>
<TableCell>
<Chips items={asset.technologies ?? []} />
</TableCell>
<TableCell>
<SourceCell asset={asset} />
</TableCell>
<TableCell className="w-16">{removeButton(asset)}</TableCell>
</TableRow>
))}
</AssetCard>
</TabsContent>
<TabsContent value="service" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard
cols={[
t("col.addressService"),
t("col.statusCode"),
t("col.title"),
t("col.responseLength"),
t("col.fingerprint"),
t("col.auth"),
t("col.source"),
t("col.action"),
]}
{...commonCardProps}
>
{rows.map((asset) => {
const isHttp = asset.service_type === "http";
const address = isHttp
? asset.url || ""
: asset.service_name || [asset.ip || asset.domain, asset.port].filter(Boolean).join(":");
return (
<TableRow key={asset.id}>
<TableCell className="max-w-xs truncate font-mono text-xs" title={address}>
{address || "—"}
</TableCell>
<TableCell>
{asset.status_code != null ? (
<span
className={cn("font-mono font-semibold text-xs tabular-nums", statusTone(asset.status_code))}
>
{asset.status_code}
</span>
) : (
"—"
)}
</TableCell>
<TableCell className="max-w-48 truncate text-xs">{asset.page_title || "—"}</TableCell>
<TableCell className="text-xs tabular-nums">{fmtBytes(asset.content_length)}</TableCell>
<TableCell>
<Chips items={asset.technologies ?? []} />
</TableCell>
<TableCell>
{(asset.auth ?? []).length === 0 ? (
<span className="text-muted-foreground text-xs">—</span>
) : (
(asset.auth ?? []).map((authItem) => {
const item = authItem as Record<string, string>;
return (
<span
key={`${asset.id}-${item.type ?? ""}-${item.username ?? ""}-${JSON.stringify(item)}`}
className="inline-flex items-center gap-1 text-[11px]"
>
<KeyRoundIcon className="size-3 text-muted-foreground" />
<span className="font-mono">{item.type || item.username || t("col.auth")}</span>
</span>
);
})
)}
</TableCell>
<TableCell>
<SourceCell asset={asset} />
</TableCell>
<TableCell className="w-16">{removeButton(asset)}</TableCell>
</TableRow>
);
})}
</AssetCard>
</TabsContent>
<TabsContent value="endpoint" className="mt-0 flex min-h-0 flex-1 flex-col">
<AssetCard
cols={[t("col.method"), t("col.fullAddress"), t("col.params"), t("col.source"), t("col.action")]}
{...commonCardProps}
>
{rows.map((asset) => (
<TableRow key={asset.id}>
<TableCell className="w-16">
<MethodBadge method={asset.method || ""} />
</TableCell>
<TableCell className="max-w-sm truncate font-mono text-xs" title={asset.url}>
{asset.url || "—"}
</TableCell>
<TableCell>
<Chips
items={(asset.params ?? []).map((param) => {
const item = param as Record<string, string>;
return item.name ? `${item.name}(${item.location || item.in || "?"})` : "?";
})}
mono
/>
</TableCell>
<TableCell>
<SourceCell asset={asset} />
</TableCell>
<TableCell className="w-16">{removeButton(asset)}</TableCell>
</TableRow>
))}
</AssetCard>
</TabsContent>
</Tabs>
<AddTaskAssetsSheet onAttached={refresh} onOpenChange={setAddOpen} open={addOpen} taskId={taskId} />
<AlertDialog open={Boolean(removeTarget)} onOpenChange={(open) => !open && !removing && setRemoveTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("remove.confirmTitle")}</AlertDialogTitle>
<AlertDialogDescription className="[overflow-wrap:anywhere]">
{removeTarget ? t("remove.confirmBody", { label: assetLabel(removeTarget) }) : ""}
{t("remove.keep")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={removing}>{t("remove.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={removing}
onClick={(event) => {
event.preventDefault();
void remove();
}}
>
{removing ? <Spinner data-icon="inline-start" /> : <Trash2Icon data-icon="inline-start" />}
{removing ? t("remove.removing") : t("remove.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,731 @@
"use client";
import * as React from "react";
import {
ArrowDownIcon,
ArrowUpIcon,
ArrowUpToLineIcon,
BugIcon,
ChevronLeftIcon,
ChevronRightIcon,
CompassIcon,
FlagIcon,
FlaskConicalIcon,
LayersIcon,
LightbulbIcon,
type LucideIcon,
PauseIcon,
PlayIcon,
SearchIcon,
TargetIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { StatusBadge } from "@/components/status-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { api } from "@/lib/api";
import { type Tone, toneClasses, toneDot } from "@/lib/status";
import type { Edge, ExploreKind, FindingAsset, TaskNode } from "@/lib/types";
import { cn } from "@/lib/utils";
const PAGE_SIZES = [20, 50, 100];
const POLL_MS = 8000;
type KindMeta = { icon: LucideIcon; dot: string; chip: string };
// 播报板自己的展示元数据。刻意不复用探索链路图那份:图是拓扑视角(节点卡片、连线配色),
// 播报是流水视角(时间轴行),两边的信息密度和配色需求不同,各自演进更省事。
// 文案走 i18n(taskDetail.broadcastTab.kind),这里只留图标与配色。
const KIND_META: Record<string, KindMeta> = {
begin: {
icon: FlagIcon,
dot: "bg-slate-500",
chip: "bg-slate-500/15 text-slate-600 dark:text-slate-300",
},
task: {
icon: FlagIcon,
dot: "bg-slate-500",
chip: "bg-slate-500/15 text-slate-600 dark:text-slate-300",
},
goal: {
icon: TargetIcon,
dot: "bg-emerald-500",
chip: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
},
intent: {
icon: CompassIcon,
dot: "bg-blue-500",
chip: "bg-blue-500/15 text-blue-600 dark:text-blue-400",
},
fact: {
icon: FlaskConicalIcon,
dot: "bg-amber-500",
chip: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
},
finding: {
icon: BugIcon,
dot: "bg-rose-500",
chip: "bg-rose-500/15 text-rose-600 dark:text-rose-400",
},
hint: {
icon: LightbulbIcon,
dot: "bg-violet-500",
chip: "bg-violet-500/15 text-violet-600 dark:text-violet-400",
},
digest: {
icon: LayersIcon,
dot: "bg-teal-500",
chip: "bg-teal-500/15 text-teal-600 dark:text-teal-400",
},
};
function kindKeyOf(kind: string): string {
return kind in KIND_META ? kind : "fact";
}
// 可筛选的类型。起点(fact/state=origin)不单独列,它跟着「事实」一起过滤。
const FILTER_KINDS: ExploreKind[] = ["goal", "intent", "fact", "finding", "hint", "digest"];
// goal / intent 的状态语义由全局 status 表提供(StatusBadge);其余类型的状态只在
// 图和播报里出现,这里补一份(文案走 i18n,这里只留配色)。
const STATE_TONE: Record<string, Record<string, Tone>> = {
fact: {
origin: "slate",
confirmed: "green",
dismissed: "slate",
},
finding: {
confirmed: "red",
dismissed: "slate",
},
hint: {
active: "violet",
consumed: "slate",
},
digest: {
active: "green",
superseded: "slate",
},
};
// 任务根是 state=origin 的 fact,播报里读作「起点」。
function viewKind(n: TaskNode): string {
return n.type === "fact" && n.state === "origin" ? "begin" : n.type;
}
const SUMMARY_FIELDS: Record<string, string[]> = {
begin: ["summary", "description"],
task: ["summary", "description"],
goal: ["text"],
intent: ["summary"],
fact: ["summary"],
finding: ["name", "summary"],
hint: ["text", "summary"],
digest: ["body", "summary"],
};
function summaryOf(n: TaskNode): string {
const raw = n.payload ?? "";
if (!raw.trim()) return "";
for (const field of SUMMARY_FIELDS[viewKind(n)] ?? []) {
try {
const obj: unknown = JSON.parse(raw);
if (obj && typeof obj === "object") {
const v = (obj as Record<string, unknown>)[field];
if (typeof v === "string" && v.trim()) return v;
}
} catch {
return raw; // 非 JSON payload:原样播报
}
}
return raw;
}
function prettyPayload(raw: string | undefined, emptyLabel: string): string {
if (!raw?.trim()) return emptyLabel;
try {
return JSON.stringify(JSON.parse(raw), null, 2);
} catch {
return raw;
}
}
const dayFmt = new Intl.DateTimeFormat("ko-KR", { month: "long", day: "numeric", weekday: "short" });
const clockFmt = new Intl.DateTimeFormat("ko-KR", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
function NodeStateBadge({ node }: { node: TaskNode }) {
const t = useTranslations("taskDetail.broadcastTab");
if (node.type === "goal") return <StatusBadge domain="goal" value={node.state} dot />;
if (node.type === "intent") return <StatusBadge domain="intent" value={node.state} dot />;
const tone = STATE_TONE[node.type]?.[node.state];
if (!tone) return null;
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium whitespace-nowrap",
toneClasses[tone],
)}
>
<span className={cn("size-1.5 rounded-full", toneDot[tone])} />
{t(`state.${node.type}.${node.state}`)}
</span>
);
}
function KindChip({ kind }: { kind: string }) {
const t = useTranslations("taskDetail.broadcastTab");
const key = kindKeyOf(kind);
const meta = KIND_META[key];
return (
<span className={cn("rounded px-1.5 py-0.5 text-xs font-medium whitespace-nowrap", meta.chip)}>
{t(`kind.${key}`)}
</span>
);
}
// 节点锚定的资产:类型标签 + 可辨识文本。数据随播报页一起下发(node id → 资产),
// 展开时直接展示,不额外请求。类型文案走 i18n(assetType),未覆盖的类型退回原始字符串。
function AssetList({ assets, dense = false }: { assets: FindingAsset[]; dense?: boolean }) {
const t = useTranslations("taskDetail.broadcastTab");
if (assets.length === 0) return null;
return (
<div>
<div className="mb-1.5 text-xs font-medium text-muted-foreground">{t("assets", { count: assets.length })}</div>
<ul className="flex flex-wrap gap-1.5">
{assets.map((a) => {
const typeLabel = t.has(`assetType.${a.type}`) ? t(`assetType.${a.type}`) : a.type;
return (
<li
key={a.id}
className={cn(
"inline-flex max-w-full items-center gap-1.5 rounded-md border bg-background px-2 py-0.5",
dense && "text-xs",
)}
title={`${typeLabel} · ${a.label}`}
>
<span className="shrink-0 rounded bg-muted px-1 text-[10px] text-muted-foreground">{typeLabel}</span>
<code className="truncate font-mono text-xs">{a.label}</code>
</li>
);
})}
</ul>
</div>
);
}
// 悬停在上下游条目上时弹出的节点名片:类型/状态/来源/时间 + 摘要 + payload 片段 + 涉及资产。
// 数据来自本页已经拿到的 refs,不额外发请求——播报接口已经把邻居节点整份带回来了。
function RelatedNodeCard({ node, assets }: { node: TaskNode; assets: FindingAsset[] }) {
const t = useTranslations("taskDetail.broadcastTab");
const kind = viewKind(node);
const ts = Date.parse(node.ts);
const summary = summaryOf(node);
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-1.5">
<KindChip kind={kind} />
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">#{node.id}</code>
<NodeStateBadge node={node} />
{node.priority > 0 && (kind === "goal" || kind === "intent") && (
<span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">P{node.priority}</span>
)}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
<span>{t("fieldType", { label: t(`kind.${kindKeyOf(kind)}`) })}</span>
<span>{t("fieldSource", { origin: node.origin || "system" })}</span>
<span>{Number.isNaN(ts) ? node.ts : new Date(ts).toLocaleString("ko-KR")}</span>
</div>
<p className="line-clamp-4 text-xs break-words">{summary || t("noSummary")}</p>
<AssetList assets={assets} dense />
<pre className="max-h-40 overflow-auto rounded border bg-muted/40 p-2 font-mono text-[11px] whitespace-pre-wrap">
{prettyPayload(node.payload, t("noPayload"))}
</pre>
</div>
);
}
// 一条播报涉及的上下游:上游 = 指向本节点的边,下游 = 本节点指出去的边。
function RelatedList({
title,
rows,
refs,
assets,
}: {
title: string;
rows: Array<{ rel: string; id: string }>;
refs: Record<string, TaskNode>;
assets: Record<string, FindingAsset[]>;
}) {
const t = useTranslations("taskDetail.broadcastTab");
if (rows.length === 0) return null;
return (
<div className="min-w-0 flex-1">
<div className="mb-1.5 text-xs font-medium text-muted-foreground">{title}</div>
<ul className="flex flex-col gap-1.5">
{rows.map((row) => {
const node = refs[row.id];
return (
<li key={`${row.rel}-${row.id}`} className="flex min-w-0 items-center gap-2 text-xs">
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-muted-foreground">
{t.has(`rel.${row.rel}`) ? t(`rel.${row.rel}`) : row.rel}
</span>
{node ? (
<HoverCard openDelay={150} closeDelay={100}>
<HoverCardTrigger asChild>
<button
type="button"
className="flex min-w-0 cursor-help items-center gap-2 text-left hover:underline"
>
<KindChip kind={viewKind(node)} />
<span className="truncate">{summaryOf(node) || t("nodeRef", { id: node.id })}</span>
</button>
</HoverCardTrigger>
<HoverCardContent align="start" className="w-96">
<RelatedNodeCard node={node} assets={assets[node.id] ?? []} />
</HoverCardContent>
</HoverCard>
) : (
<span className="text-muted-foreground">{t("nodeRef", { id: row.id })}</span>
)}
</li>
);
})}
</ul>
</div>
);
}
function BroadcastRow({
node,
edges,
refs,
assets,
now,
fresh,
}: {
node: TaskNode;
edges: Edge[];
refs: Record<string, TaskNode>;
assets: Record<string, FindingAsset[]>;
now: number;
fresh: boolean;
}) {
const t = useTranslations("taskDetail.broadcastTab");
const [open, setOpen] = React.useState(false);
const kind = viewKind(node);
const meta = KIND_META[kindKeyOf(kind)];
const Icon = meta.icon;
const ts = Date.parse(node.ts);
const summary = summaryOf(node);
const upstream = edges.filter((e) => e.dst === node.id).map((e) => ({ rel: e.rel, id: e.src }));
const downstream = edges.filter((e) => e.src === node.id).map((e) => ({ rel: e.rel, id: e.dst }));
// 相对时间:阈值切档,文案与单位走 i18n。ts 解析失败(NaN)时返回空串,不显示。
const relTime = (tsMs: number, nowMs: number): string => {
if (!nowMs || !tsMs) return "";
const sec = Math.max(0, (nowMs - tsMs) / 1000);
if (sec < 60) return t("relTime.now");
if (sec < 3600) return t("relTime.minutes", { count: Math.floor(sec / 60) });
if (sec < 86400) return t("relTime.hours", { count: Math.floor(sec / 3600) });
return t("relTime.days", { count: Math.floor(sec / 86400) });
};
return (
<div className={cn("relative grid grid-cols-[4.5rem_1.75rem_1fr] gap-x-2", fresh && "bg-primary/5")}>
{/* 时间列 */}
<div className="py-3 text-right text-xs text-muted-foreground tabular-nums">
<div>{Number.isNaN(ts) ? "--:--:--" : clockFmt.format(ts)}</div>
<div className="text-[11px] opacity-70">{relTime(ts, now)}</div>
</div>
{/* 时间轴:竖线 + 类型圆点 */}
<div className="relative flex justify-center">
<span className="absolute inset-y-0 w-px bg-border" />
<span
className={cn(
"relative mt-3.5 flex size-6 items-center justify-center rounded-full text-white ring-4 ring-background",
meta.dot,
)}
>
<Icon className="size-3.5" />
</span>
</div>
{/* 内容列 */}
<div className="min-w-0 border-b py-3 pr-1 last:border-b-0">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full min-w-0 items-start gap-2 text-left"
>
<ChevronRightIcon
className={cn("mt-0.5 size-3.5 shrink-0 text-muted-foreground transition-transform", open && "rotate-90")}
/>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<KindChip kind={kind} />
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">#{node.id}</code>
<NodeStateBadge node={node} />
{node.priority > 0 && (kind === "goal" || kind === "intent") && (
<span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">P{node.priority}</span>
)}
{fresh && (
<span className="rounded bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground">
{t("fresh")}
</span>
)}
<span className="ml-auto shrink-0 text-xs text-muted-foreground">{node.origin || "system"}</span>
</div>
<p className={cn("mt-1 text-sm", !open && "line-clamp-2")}>{summary || t("nodeRef", { id: node.id })}</p>
</div>
</button>
{open && (
<div className="mt-2 ml-5 flex flex-col gap-3 rounded-md border bg-muted/30 p-3">
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>
{t("node")} <code className="font-mono">#{node.id}</code>
</span>
<span>{t("fieldType", { label: t(`kind.${kindKeyOf(kind)}`) })}</span>
<span>{t("fieldSource", { origin: node.origin || "system" })}</span>
<span>{Number.isNaN(ts) ? node.ts : new Date(ts).toLocaleString("ko-KR")}</span>
</div>
{node.state === "deleted" && node.delete_reason && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs">
<span className="font-medium text-destructive">{t("deleteReason")}</span>
<span className="ml-2 break-words text-muted-foreground">{node.delete_reason}</span>
</div>
)}
<AssetList assets={assets[node.id] ?? []} />
{(upstream.length > 0 || downstream.length > 0) && (
<div className="flex flex-col gap-3 sm:flex-row">
<RelatedList title={t("upstream")} rows={upstream} refs={refs} assets={assets} />
<RelatedList title={t("downstream")} rows={downstream} refs={refs} assets={assets} />
</div>
)}
<div>
<div className="mb-1.5 text-xs font-medium text-muted-foreground">payload</div>
<pre className="max-h-64 overflow-auto rounded-md border bg-background p-3 font-mono text-xs whitespace-pre-wrap">
{prettyPayload(node.payload, t("noPayload"))}
</pre>
</div>
</div>
)}
</div>
</div>
);
}
export function BroadcastTab({ taskId }: { taskId: string }) {
const t = useTranslations("taskDetail.broadcastTab");
const tp = useTranslations("pagination");
const [kinds, setKinds] = React.useState<ExploreKind[]>([]);
const [queryInput, setQueryInput] = React.useState("");
const [query, setQuery] = React.useState("");
const [order, setOrder] = React.useState<"asc" | "desc">("desc");
const [page, setPage] = React.useState(1);
const [size, setSize] = React.useState(20);
const [live, setLive] = React.useState(true);
const [items, setItems] = React.useState<TaskNode[]>([]);
const [edges, setEdges] = React.useState<Edge[]>([]);
const [refs, setRefs] = React.useState<Record<string, TaskNode>>({});
const [assets, setAssets] = React.useState<Record<string, FindingAsset[]>>({});
const [total, setTotal] = React.useState(0);
const [loaded, setLoaded] = React.useState(false);
const [freshIDs, setFreshIDs] = React.useState<Set<string>>(new Set());
const [pending, setPending] = React.useState(0);
const [now, setNow] = React.useState(0);
const seenRef = React.useRef<Set<string>>(new Set());
const baselineRef = React.useRef<number | null>(null);
const streamRef = React.useRef("");
// 只有「最新在前的第 1 页」才是真正的直播位;其余位置轮询只更新未读计数,
// 不动列表,免得翻页/展开时内容在脚下变。
const atLive = page === 1 && order === "desc";
React.useEffect(() => {
setNow(Date.now());
const t = setInterval(() => setNow(Date.now()), 30000);
return () => clearInterval(t);
}, []);
// 输入防抖:打字停 300ms 才真正查询,并回到第一页。
React.useEffect(() => {
const t = setTimeout(() => {
setQuery(queryInput);
setPage(1);
}, 300);
return () => clearTimeout(t);
}, [queryInput]);
React.useEffect(() => {
let alive = true;
let rendered = false; // 本次查询是否已经把内容渲染出来过
// 换任务/筛选/排序 = 换了一条播报流:清掉「新」标记和未读基线。翻页不算换流,
// 否则回到最新时就没有未读计数可算了。
const stream = `${taskId}|${kinds.join(",")}|${query}|${order}`;
if (streamRef.current !== stream) {
streamRef.current = stream;
seenRef.current = new Set();
baselineRef.current = null;
setPending(0);
}
const load = () =>
api
.explorationNodes(taskId, { page, size, kinds, q: query, order })
.then((r) => {
if (!alive) return;
// 直播位每轮都刷新;其它位置只渲染第一次,之后轮询仅更新未读计数。
if (atLive || !rendered) {
rendered = true;
setItems(r.items);
setEdges(r.edges);
setRefs(r.refs);
setAssets(r.assets);
}
setTotal(r.total);
if (atLive) {
const seen = seenRef.current;
setFreshIDs(seen.size === 0 ? new Set() : new Set(r.items.filter((n) => !seen.has(n.id)).map((n) => n.id)));
seenRef.current = new Set(r.items.map((n) => n.id));
baselineRef.current = r.total;
setPending(0);
} else {
const base = baselineRef.current;
setPending(base === null ? 0 : Math.max(0, r.total - base));
}
setLoaded(true);
})
.catch(() => {
// 轮询是尽力而为:保留上一次成功的播报内容,下一轮自动重试。
});
void load();
if (!live) {
return () => {
alive = false;
};
}
const timer = setInterval(load, POLL_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [taskId, page, size, kinds, query, order, live, atLive]);
const toggleKind = (kind: ExploreKind) => {
setKinds((cur) => (cur.includes(kind) ? cur.filter((k) => k !== kind) : [...cur, kind]));
setPage(1);
};
const backToLive = () => {
setPage(1);
setOrder("desc");
setPending(0);
};
// 服务端的 refs 只补「不在本页的邻居」,同页节点之间的引用要靠 items 自己兜底,
// 否则相邻两条播报互相引用时会退化成光秃秃的「节点 #id」。
const nodeIndex = React.useMemo(() => {
const idx: Record<string, TaskNode> = { ...refs };
for (const n of items) idx[n.id] = n;
return idx;
}, [refs, items]);
const pageCount = Math.max(1, Math.ceil(total / size));
const start = total === 0 ? 0 : (page - 1) * size + 1;
const end = (page - 1) * size + items.length;
// 换任务或筛选后条数变少时,把越界的页码收回来。
React.useEffect(() => {
if (page > pageCount) setPage(pageCount);
}, [page, pageCount]);
// 按天分组:播报流按日期断行,长任务翻页时还能认出「这是哪天的事」。
const groups: Array<{ day: string; rows: TaskNode[] }> = [];
for (const node of items) {
const ts = Date.parse(node.ts);
const day = Number.isNaN(ts) ? t("unknownDay") : dayFmt.format(ts);
const last = groups[groups.length - 1];
if (last && last.day === day) last.rows.push(node);
else groups.push({ day, rows: [node] });
}
return (
<Card className="overflow-hidden py-0">
{/* 工具条 */}
<div className="flex flex-wrap items-center gap-2 border-b px-4 py-2.5">
<div className="relative w-full sm:w-64">
<SearchIcon className="absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={queryInput}
onChange={(e) => setQueryInput(e.target.value)}
placeholder={t("toolbar.searchPlaceholder")}
className="h-8 pl-8"
aria-label={t("toolbar.searchAria")}
/>
</div>
<div className="flex flex-wrap items-center gap-1">
{FILTER_KINDS.map((kind) => {
const meta = KIND_META[kind];
const active = kinds.includes(kind);
return (
<button
key={kind}
type="button"
onClick={() => toggleKind(kind)}
aria-pressed={active}
className={cn(
"rounded-md border px-2 py-0.5 text-xs font-medium transition-colors",
active ? meta.chip : "border-transparent text-muted-foreground hover:bg-accent",
)}
>
{t(`kind.${kind}`)}
</button>
);
})}
{kinds.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => {
setKinds([]);
setPage(1);
}}
>
{t("toolbar.clear")}
</Button>
)}
</div>
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => {
setOrder((o) => (o === "desc" ? "asc" : "desc"));
setPage(1);
}}
aria-label={order === "desc" ? t("toolbar.orderDescAria") : t("toolbar.orderAscAria")}
>
{order === "desc" ? <ArrowDownIcon /> : <ArrowUpIcon />}
{order === "desc" ? t("toolbar.newest") : t("toolbar.oldest")}
</Button>
<Button
variant={live ? "outline" : "secondary"}
size="sm"
className="h-8"
onClick={() => setLive((v) => !v)}
aria-label={live ? t("toolbar.pauseAria") : t("toolbar.resumeAria")}
>
{live ? <PauseIcon /> : <PlayIcon />}
{live ? t("toolbar.live") : t("toolbar.paused")}
</Button>
</div>
</div>
{/* 离开直播位时的未读提示 */}
{!atLive && pending > 0 && (
<button
type="button"
onClick={backToLive}
className="flex w-full items-center justify-center gap-1.5 border-b bg-primary/10 py-1.5 text-xs font-medium text-primary hover:bg-primary/15"
>
<ArrowUpToLineIcon className="size-3.5" />
{t("pending", { count: pending > 99 ? "99+" : pending })}
</button>
)}
<CardContent className="px-4 py-0">
{!loaded ? (
<div className="flex flex-col gap-3 py-4">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : items.length === 0 ? (
<p className="py-10 text-center text-sm text-muted-foreground">
{query || kinds.length > 0 ? t("emptyFiltered") : t("emptyNoNodes")}
</p>
) : (
groups.map((group) => (
<div key={group.day}>
<div className="py-2 pl-[6.25rem] text-xs font-medium text-muted-foreground">{group.day}</div>
{group.rows.map((node) => (
<BroadcastRow
key={node.id}
node={node}
edges={edges}
refs={nodeIndex}
assets={assets}
now={now}
fresh={freshIDs.has(node.id)}
/>
))}
</div>
))
)}
</CardContent>
<CardFooter className="flex flex-wrap items-center gap-2 border-t px-4 py-2.5 text-xs text-muted-foreground">
<Select
value={String(size)}
onValueChange={(v) => {
setSize(Number(v));
setPage(1);
}}
>
<SelectTrigger size="sm" className="h-7 w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{PAGE_SIZES.map((n) => (
<SelectItem key={n} value={String(n)}>
{n}
{tp("perPage")}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<span className="tabular-nums">{tp("range", { from: start, to: end, total })}</span>
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="icon-sm"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
aria-label={tp("prev")}
>
<ChevronLeftIcon />
</Button>
<span className="tabular-nums">
{page} / {pageCount}
</span>
<Button
variant="outline"
size="icon-sm"
disabled={page >= pageCount}
onClick={() => setPage((p) => Math.min(pageCount, p + 1))}
aria-label={tp("next")}
>
<ChevronRightIcon />
</Button>
</div>
</CardFooter>
</Card>
);
}
@@ -0,0 +1,733 @@
"use client";
import * as React from "react";
import type { Graph as G6Graph } from "@antv/g6";
import { AppWindow, Building2, Globe, Link2, type LucideIcon, Radio, RefreshCw, Server, Waypoints } from "lucide-react";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { api } from "@/lib/api";
import type { CoverageAssetRef, CoverageAssetRefs, CoverageGraphEdge, CoverageGraphNode } from "@/lib/types";
import { cn } from "@/lib/utils";
// 每个父节点下、同一类型的子节点默认展示的数量;超出折叠,"展示更多"每次再拉这么多。
const FOLD_LIMIT = 20;
const FOLD_STEP = 20;
type Kind = CoverageGraphNode["kind"];
// label 은 더 이상 여기 두지 않는다. 노드 유형 이름은 화면에 노출되므로
// `taskDetail.coverageGraph.kind.*` 에서 번역해 가져온다(캔버스 라벨 포함).
type KindMeta = { icon: LucideIcon; iconBg: string; hex: string; size: number };
const kindMeta: Record<Kind, KindMeta> = {
company: { icon: Building2, iconBg: "bg-slate-500", hex: "#64748b", size: 46 },
root_domain: { icon: Globe, iconBg: "bg-indigo-500", hex: "#6366f1", size: 38 },
subdomain: { icon: Waypoints, iconBg: "bg-blue-500", hex: "#3b82f6", size: 30 },
ip: { icon: Server, iconBg: "bg-cyan-600", hex: "#0891b2", size: 28 },
service: { icon: Radio, iconBg: "bg-amber-500", hex: "#f59e0b", size: 26 },
app: { icon: AppWindow, iconBg: "bg-fuchsia-500", hex: "#d946ef", size: 26 },
endpoint: { icon: Link2, iconBg: "bg-rose-500", hex: "#f43f5e", size: 20 },
};
// G6 节点图标用平台一致的 lucide 图标:把 lucide 的 SVG 路径(v1.22)渲染成白色描边的
// data URI,作为节点 iconSrc(白色在实色/灰色底上都清晰)。手写内嵌,避免 react-dom/server
// 在 React19/Next 客户端打包的问题。
function svgUri(inner: string, filled = false): string {
const attrs = filled
? 'fill="#fff" stroke="none"'
: 'fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" ${attrs}>${inner}</svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
const KIND_ICON: Record<Kind, string> = {
company: svgUri(
'<path d="M10 12h4"/><path d="M10 8h4"/><path d="M14 21v-3a2 2 0 0 0-4 0v3"/><path d="M6 10H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-2"/><path d="M6 21V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v16"/>',
),
root_domain: svgUri(
'<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
),
subdomain: svgUri(
'<path d="m10.586 5.414-5.172 5.172"/><path d="m18.586 13.414-5.172 5.172"/><path d="M6 12h12"/><circle cx="12" cy="20" r="2"/><circle cx="12" cy="4" r="2"/><circle cx="20" cy="12" r="2"/><circle cx="4" cy="12" r="2"/>',
),
ip: svgUri(
'<rect width="20" height="8" x="2" y="2" rx="2" ry="2"/><rect width="20" height="8" x="2" y="14" rx="2" ry="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/>',
),
service: svgUri(
'<path d="M16.247 7.761a6 6 0 0 1 0 8.478"/><path d="M19.075 4.933a10 10 0 0 1 0 14.134"/><path d="M4.925 19.067a10 10 0 0 1 0-14.134"/><path d="M7.753 16.239a6 6 0 0 1 0-8.478"/><circle cx="12" cy="12" r="2"/>',
),
app: svgUri(
'<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M10 4v4"/><path d="M2 8h20"/><path d="M6 4v4"/>',
),
endpoint: svgUri(
'<path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 1 1 0 10h-2"/><line x1="8" x2="16" y1="12" y2="12"/>',
),
};
const FOLD_ICON = svgUri(
'<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>',
true,
);
// ---------------------------------------------------------------------------
// Folding: full graph → currently-visible node/edge set (自顶向下级联折叠)。
// ---------------------------------------------------------------------------
type FoldNode = {
fold: true;
key: string;
groupId: string;
parentKey: string;
kind: Kind;
hidden: CoverageGraphNode[];
};
type AssetRenderNode = { fold: false; key: string; kind: Kind; node: CoverageGraphNode };
type RenderNode = AssetRenderNode | FoldNode;
function sortChildren(a: CoverageGraphNode, b: CoverageGraphNode): number {
if (a.tested !== b.tested) return a.tested ? -1 : 1;
return (a.label || "").localeCompare(b.label || "");
}
function computeVisible(
nodes: CoverageGraphNode[],
edges: CoverageGraphEdge[],
expanded: Map<string, number>,
): { renderNodes: RenderNode[]; renderEdges: CoverageGraphEdge[] } {
const byKey = new Map(nodes.map((n) => [n.key, n]));
const parentOf = new Map<string, string>();
const childrenOf = new Map<string, string[]>();
for (const e of edges) {
if (!byKey.has(e.src) || !byKey.has(e.dst)) continue;
if (!parentOf.has(e.src)) parentOf.set(e.src, e.dst);
const arr = childrenOf.get(e.dst);
if (arr) arr.push(e.src);
else childrenOf.set(e.dst, [e.src]);
}
const renderNodes: RenderNode[] = [];
const visible = new Set<string>();
const foldNodes: FoldNode[] = [];
const queue: string[] = [];
for (const n of nodes) {
if (!parentOf.has(n.key)) {
queue.push(n.key);
visible.add(n.key);
}
}
for (let qi = 0; qi < queue.length; qi++) {
const parent = queue[qi];
const kids = childrenOf.get(parent) ?? [];
if (kids.length === 0) continue;
const groups = new Map<Kind, CoverageGraphNode[]>();
for (const ck of kids) {
const cn = byKey.get(ck);
if (!cn) continue;
const g = groups.get(cn.kind);
if (g) g.push(cn);
else groups.set(cn.kind, [cn]);
}
for (const [kind, arr] of groups) {
arr.sort(sortChildren);
const groupId = `${parent}::${kind}`;
const shown = expanded.get(groupId) ?? FOLD_LIMIT;
const visibleChildren = arr.slice(0, shown);
const hidden = arr.slice(shown);
for (const c of visibleChildren) {
if (!visible.has(c.key)) {
visible.add(c.key);
queue.push(c.key);
}
}
if (hidden.length > 0) {
foldNodes.push({ fold: true, key: `fold:${groupId}`, groupId, parentKey: parent, kind, hidden });
}
}
}
for (const n of nodes) {
if (visible.has(n.key)) renderNodes.push({ fold: false, key: n.key, kind: n.kind, node: n });
}
const renderEdges: CoverageGraphEdge[] = edges.filter((e) => visible.has(e.src) && visible.has(e.dst));
for (const f of foldNodes) {
renderNodes.push(f);
renderEdges.push({ src: f.key, dst: f.parentKey });
}
return { renderNodes, renderEdges };
}
// ---------------------------------------------------------------------------
// G6 数据映射。自定义字段放节点顶层(G6 v5 官方 force 示例约定:style/layout 回调直接读 d.<field>)。
// ---------------------------------------------------------------------------
type G6NodeDatum = {
id: string;
kind: Kind;
fold: boolean;
tested: boolean;
inScope: boolean;
lbl: string;
size: number;
};
function trunc(s: string, n = 26): string {
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
}
// 图里的节点文案:服务不显示完整 URL,只显示 端口·标题·状态码;端点只显示 path。
// 其余类型沿用后端给的 label。
function graphLabel(n: CoverageGraphNode): string {
if (n.kind === "service") {
const parts: string[] = [];
if (n.port) parts.push(`:${n.port}`);
if (n.page_title) parts.push(n.page_title);
if (n.status_code) parts.push(String(n.status_code));
// biome-ignore lint/nursery/useNullishCoalescing: 표시용 폴백 — 빈 문자열도 다음 후보(ip·label)로 넘겨야 하므로 || 가 의도된 동작이다(?? 는 빈 문자열을 그대로 노출한다)
return parts.length ? parts.join(" · ") : n.domain || n.ip || n.label;
}
if (n.kind === "endpoint" && n.url) {
try {
return new URL(n.url).pathname || "/";
} catch {
/* 非法 URL:回退到完整 label */
}
}
return n.label;
}
function toG6Nodes(
renderNodes: RenderNode[],
kindLabel: (k: Kind) => string,
moreLabel: (count: number, k: Kind) => string,
): G6NodeDatum[] {
return renderNodes.map((rn) => {
if (rn.fold) {
return {
id: rn.key,
kind: rn.kind,
fold: true,
tested: false,
inScope: false,
lbl: moreLabel(rn.hidden.length, rn.kind),
size: 24,
};
}
return {
id: rn.key,
kind: rn.kind,
fold: false,
tested: rn.node.tested,
inScope: rn.node.in_scope,
lbl: trunc(graphLabel(rn.node) || kindLabel(rn.kind)),
size: kindMeta[rn.kind].size,
};
});
}
// G6 的类型把自定义字段归在 data 下,但官方 force 示例(及运行时)按顶层读 d.<field>。
// 回调形参用 unknown 满足 G6 签名,内部用 nd() 强转回我们的顶层结构。
const nd = (d: unknown) => d as G6NodeDatum;
// 已测=实色高亮;范围内未测=灰;范围外/折叠=更淡的灰 + 虚线描边。
function nodeFill(d: G6NodeDatum): string {
if (d.fold) return "#f1f5f9";
if (!d.inScope) return "#e2e8f0";
if (d.tested) return kindMeta[d.kind].hex;
return "#94a3b8";
}
function nodeStroke(d: G6NodeDatum): string {
if (d.fold || !d.inScope) return "#94a3b8";
if (d.tested) return "#0f766e";
return "#64748b";
}
// ---------------------------------------------------------------------------
// 抽屉:资产节点看详情;折叠节点看隐藏列表 + "展示更多"。
// ---------------------------------------------------------------------------
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
if (children === undefined || children === null || children === "") return null;
return (
<div className="flex items-start gap-3 py-1 text-sm">
<span className="text-muted-foreground w-16 shrink-0">{label}</span>
<span className="text-foreground min-w-0 flex-1 break-words">{children}</span>
</div>
);
}
function RefList({ title, items }: { title: string; items: CoverageAssetRef[] }) {
const t = useTranslations("taskDetail.coverageGraph");
if (items.length === 0) return null;
return (
<div>
<h4 className="text-muted-foreground mb-1 text-xs font-medium">
{title} ({items.length})
</h4>
<div className="flex flex-col gap-1">
{items.map((r) => (
<div
key={`${r.kind}-${r.id}`}
className="flex flex-wrap items-start gap-2 rounded-md bg-muted/50 px-2 py-1.5 text-xs"
>
<span className="shrink-0 font-mono text-muted-foreground">#{r.id}</span>
{r.state && <span className="shrink-0 text-muted-foreground">{r.state}</span>}
<span className="min-w-32 flex-1 break-words text-foreground">{r.summary || "—"}</span>
{r.inherited && r.source_task_id && (
<Badge variant="outline" className="shrink-0">
{t("sheet.refSource", { id: r.source_task_id })}
</Badge>
)}
</div>
))}
</div>
</div>
);
}
function AssetSheet({
node,
taskId,
onOpenChange,
}: {
node: CoverageGraphNode | null;
taskId: string;
onOpenChange: (open: boolean) => void;
}) {
const t = useTranslations("taskDetail.coverageGraph");
const meta = node ? kindMeta[node.kind] : null;
const Icon = meta?.icon;
const kindLabel = node ? t(`kind.${node.kind}`) : "";
const raw = node ? JSON.stringify(node, null, 2) : "";
const [refs, setRefs] = React.useState<CoverageAssetRefs | null>(null);
React.useEffect(() => {
setRefs(null);
if (!node?.asset_id) return;
let cancelled = false;
api
.taskAssetRefs(taskId, node.asset_id)
.then((r) => {
if (!cancelled) setRefs(r);
})
.catch(() => {
/* 无关联或出错:不展示该区块 */
});
return () => {
cancelled = true;
};
}, [node, taskId]);
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">{kindLabel}</SheetTitle>
<span className="text-muted-foreground truncate font-mono text-xs" title={node.label}>
{node.label}
</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">{t("sheet.attributes")}</h4>
<DetailRow label={t("sheet.attrType")}>{kindLabel}</DetailRow>
<DetailRow label={t("sheet.attrTestState")}>
{node.in_scope ? (
node.tested ? (
<span className="text-emerald-600 dark:text-emerald-400">{t("sheet.tested")}</span>
) : (
<span className="text-neutral-500">{t("sheet.untested")}</span>
)
) : (
<span className="text-neutral-400">{t("sheet.outOfScope")}</span>
)}
</DetailRow>
<DetailRow label={t("sheet.attrDomain")}>{node.domain}</DetailRow>
<DetailRow label={t("sheet.attrRootDomain")}>{node.root_domain}</DetailRow>
<DetailRow label="IP">{node.ip}</DetailRow>
<DetailRow label={t("sheet.attrPort")}>{node.port ? node.port : undefined}</DetailRow>
<DetailRow label="URL">
{node.url ? <span className="font-mono text-xs break-all">{node.url}</span> : undefined}
</DetailRow>
<DetailRow label={t("sheet.attrTitle")}>{node.page_title}</DetailRow>
<DetailRow label={t("sheet.attrStatusCode")}>
{node.status_code ? node.status_code : undefined}
</DetailRow>
<DetailRow label={t("sheet.attrApp")}>{node.app_name}</DetailRow>
<DetailRow label={t("sheet.attrAssetId")}>
{node.asset_id ? <span className="font-mono text-xs">{node.asset_id}</span> : undefined}
</DetailRow>
</section>
{refs && (refs.intents.length > 0 || refs.facts.length > 0 || refs.findings.length > 0) && (
<section className="flex flex-col gap-3 border-t pt-3">
<RefList title={t("sheet.refIntents")} items={refs.intents} />
<RefList title={t("sheet.refFacts")} items={refs.facts} />
<RefList title={t("sheet.refFindings")} items={refs.findings} />
</section>
)}
<section className="border-t pt-3">
<h4 className="text-muted-foreground mb-1.5 text-xs font-medium">{t("sheet.rawData")}</h4>
<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 FoldSheet({
fold,
onOpenChange,
onShowMore,
onPick,
}: {
fold: FoldNode | null;
onOpenChange: (open: boolean) => void;
onShowMore: (groupId: string) => void;
onPick: (n: CoverageGraphNode) => void;
}) {
const t = useTranslations("taskDetail.coverageGraph");
const meta = fold ? kindMeta[fold.kind] : null;
const Icon = meta?.icon;
const kindLabel = fold ? t(`kind.${fold.kind}`) : "";
return (
<Sheet open={fold !== null} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 data-[side=right]:sm:max-w-md">
{fold && meta && Icon && (
<>
<SheetHeader className="border-b p-4">
<SheetTitle className="text-base">
{t("sheet.foldTitle", { kind: kindLabel, count: fold.hidden.length })}
</SheetTitle>
<p className="text-muted-foreground text-xs">{t("sheet.foldHint")}</p>
</SheetHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-1 p-3">
{fold.hidden.map((n) => (
<button
key={n.key}
type="button"
onClick={() => onPick(n)}
className="hover:bg-accent flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm"
>
<span
className={cn(
"flex size-5 shrink-0 items-center justify-center rounded",
n.tested ? meta.iconBg : "bg-neutral-400 dark:bg-neutral-600",
)}
>
<Icon className="size-3 text-white" />
</span>
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={n.label}>
{n.label}
</span>
{n.tested && <span className="size-1.5 shrink-0 rounded-full bg-emerald-500" />}
</button>
))}
</div>
</ScrollArea>
<div className="border-t p-3">
<Button className="w-full" variant="outline" onClick={() => onShowMore(fold.groupId)}>
{t("sheet.showMore", { step: FOLD_STEP })}
</Button>
</div>
</>
)}
</SheetContent>
</Sheet>
);
}
// ---------------------------------------------------------------------------
function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; coverageEnabled?: boolean }) {
const [data, setData] = React.useState<{
nodes: CoverageGraphNode[];
edges: CoverageGraphEdge[];
} | null>(null);
const [expanded, setExpanded] = React.useState<Map<string, number>>(new Map());
const [selectedAsset, setSelectedAsset] = React.useState<CoverageGraphNode | null>(null);
const [selectedFoldId, setSelectedFoldId] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(true);
const t = useTranslations("taskDetail.coverageGraph");
const kindLabel = React.useCallback((k: Kind) => t(`kind.${k}`), [t]);
const moreLabel = React.useCallback(
(count: number, k: Kind) => t("graph.moreCount", { kind: t(`kind.${k}`), count }),
[t],
);
const containerRef = React.useRef<HTMLDivElement>(null);
const graphRef = React.useRef<G6Graph | null>(null);
// click 处理需要最新的 key→RenderNode 映射(G6 事件回调闭包外读 ref)。
const renderMapRef = React.useRef<Map<string, RenderNode>>(new Map());
const gDataRef = React.useRef<{ nodes: G6NodeDatum[]; edges: { source: string; target: string }[] }>({
nodes: [],
edges: [],
});
const fetchGraph = React.useCallback(() => {
setLoading(true);
api
.taskCoverageGraph(taskId)
.then((g) => {
// 资产覆盖度功能关闭(B1):仍出图(范围内资产/company 关联依旧可见),但抹平
// tested 状态——不显示测试进度、不做已测高亮。
const nodes = coverageEnabled ? (g.nodes ?? []) : (g.nodes ?? []).map((n) => ({ ...n, tested: false }));
setData({ nodes, edges: g.edges ?? [] });
})
.catch(() => {
/* 保留上一次数据 */
})
.finally(() => setLoading(false));
}, [taskId, coverageEnabled]);
React.useEffect(() => {
fetchGraph();
}, [fetchGraph]);
const { renderNodes, renderEdges } = React.useMemo(
() =>
data
? computeVisible(data.nodes, data.edges, expanded)
: { renderNodes: [] as RenderNode[], renderEdges: [] as CoverageGraphEdge[] },
[data, expanded],
);
// 结构签名:只在可见节点/边集合变化时重建图 + 重跑布局,避免无谓抖动。
const sig = React.useMemo(
() =>
`${renderNodes
.map((n) => `${n.key}:${n.fold ? "f" : n.node.tested ? "t" : "u"}`)
.sort()
.join(",")}|${renderEdges.length}`,
[renderNodes, renderEdges],
);
// 维护 gDataRef + renderMapRef(供事件与图数据应用读取)。
gDataRef.current = {
nodes: toG6Nodes(renderNodes, kindLabel, moreLabel),
edges: renderEdges.map((e) => ({ source: e.src, target: e.dst })),
};
const rmap = new Map<string, RenderNode>();
for (const rn of renderNodes) rmap.set(rn.key, rn);
renderMapRef.current = rmap;
const applyData = React.useCallback(() => {
const graph = graphRef.current;
if (!graph || graph.destroyed) return;
graph.setData(gDataRef.current);
// render() 异步跑 d3-force 布局;若组件在布局落地前被卸载/销毁,g6 会在已清空的
// context 上访问 transform 抛错(见 runtime/layout transformDataAfterLayout)。这是纯
// teardown 竞态,吞掉它,不影响功能;真正的渲染错误(图未销毁)仍打日志。
void graph.render().catch((err) => {
if (!graph.destroyed) console.error("[coverage-graph] render:", err);
});
}, []);
// 建图(一次)。动态 import 避开 SSR/静态导出期的 window 依赖。
React.useEffect(() => {
let destroyed = false;
let graph: G6Graph | null = null;
void (async () => {
const { Graph } = await import("@antv/g6");
if (destroyed || !containerRef.current) return;
graph = new Graph({
container: containerRef.current,
autoResize: true,
autoFit: "view",
background: "#f0f2f7",
node: {
style: {
size: (d: unknown) => nd(d).size,
fill: (d: unknown) => nodeFill(nd(d)),
stroke: (d: unknown) => nodeStroke(nd(d)),
lineWidth: (d: unknown) => (nd(d).fold || !nd(d).inScope ? 1 : 1.5),
lineDash: (d: unknown) => (nd(d).fold || !nd(d).inScope ? [3, 3] : [0]),
iconSrc: (d: unknown) => (nd(d).fold ? FOLD_ICON : KIND_ICON[nd(d).kind]),
iconWidth: (d: unknown) => Math.max(12, nd(d).size * 0.55),
iconHeight: (d: unknown) => Math.max(12, nd(d).size * 0.55),
labelText: (d: unknown) => nd(d).lbl,
labelFontSize: 10,
labelPlacement: "bottom",
labelFill: "#475569",
labelBackground: true,
labelBackgroundFill: "rgba(255,255,255,0.75)",
labelBackgroundRadius: 3,
labelPadding: [1, 3],
},
},
edge: {
style: { stroke: "#cbd5e1", lineWidth: 1, endArrow: false },
},
layout: {
type: "d3-force",
collide: { radius: (d: unknown) => (nd(d).size ? nd(d).size : 20) + 8 },
link: {
distance: (edge: unknown) => {
// 顶层(公司/根域名)离子节点远一点,叶子近一点。edge.source 可能是 id 或已解析节点。
const s = (edge as { source: string | { id?: string } }).source;
const srcId = typeof s === "string" ? s : (s?.id ?? "");
const src = renderMapRef.current.get(srcId);
const k = src && !src.fold ? src.kind : "endpoint";
return k === "company" || k === "root_domain" ? 120 : 60;
},
},
manyBody: {
strength: (d: unknown) => (nd(d).kind === "endpoint" || nd(d).fold ? -60 : -200),
},
},
behaviors: ["drag-element-force", "drag-canvas", "zoom-canvas"],
});
graph.on("node:click", (evt: unknown) => {
const id = (evt as { target?: { id?: string } }).target?.id;
if (!id) return;
const rn = renderMapRef.current.get(id);
if (!rn) return;
if (rn.fold) setSelectedFoldId(rn.key);
else setSelectedAsset(rn.node);
});
graphRef.current = graph;
applyData();
})();
return () => {
destroyed = true;
// 先停布局再销毁:尽量缩短"布局在飞、context 被清空"的竞态窗口。
try {
graph?.stopLayout();
} catch {
/* 图可能尚未建成或已无布局上下文 */
}
graph?.destroy();
graphRef.current = null;
};
}, [applyData]);
// 可见集合变化 → 重新灌数据 + 布局。sig 只作为重排触发器(applyData 读 gDataRef)。
// biome-ignore lint/correctness/useExhaustiveDependencies: sig 是刻意的重排触发依赖
React.useEffect(() => {
applyData();
}, [sig, applyData]);
const showMore = React.useCallback((groupId: string) => {
setExpanded((prev) => {
const m = new Map(prev);
m.set(groupId, (m.get(groupId) ?? FOLD_LIMIT) + FOLD_STEP);
return m;
});
}, []);
const selectedFold =
selectedFoldId != null
? ((renderNodes.find((n) => n.fold && n.key === selectedFoldId) as FoldNode | undefined) ?? null)
: null;
React.useEffect(() => {
if (selectedFoldId != null && !selectedFold) setSelectedFoldId(null);
}, [selectedFoldId, selectedFold]);
const total = data?.nodes.length ?? 0;
const tested = data?.nodes.filter((n) => n.in_scope && n.tested).length ?? 0;
const inScope = data?.nodes.filter((n) => n.in_scope).length ?? 0;
return (
<div className="relative h-full w-full">
<div ref={containerRef} className="h-full w-full" />
{/* 图例 + 统计 + 刷新(叠加层) */}
<div className="bg-card/95 pointer-events-auto absolute top-3 left-3 flex max-w-[340px] flex-col gap-2.5 rounded-lg border p-3 text-xs shadow-sm backdrop-blur">
<div className="flex items-center justify-between gap-3">
{total > 0 ? (
<span className="text-muted-foreground">
{t("graph.scopeIn")} <span className="text-foreground font-semibold tabular-nums">{inScope}</span>
{coverageEnabled && (
<>
{" "}
· {t("graph.tested")}{" "}
<span className="font-semibold tabular-nums text-emerald-600 dark:text-emerald-400">{tested}</span>
</>
)}
</span>
) : (
<span className="text-muted-foreground">{loading ? t("graph.loading") : t("graph.empty")}</span>
)}
<Button variant="ghost" size="icon" className="size-6" onClick={fetchGraph} title={t("graph.refresh")}>
<RefreshCw className={cn("size-3.5", loading && "animate-spin")} />
</Button>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1.5">
{(Object.keys(kindMeta) as Kind[]).map((k) => {
const m = kindMeta[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>
{t(`kind.${k}`)}
</span>
);
})}
</div>
<div className="border-border/60 text-muted-foreground flex flex-wrap gap-x-3 gap-y-1.5 border-t pt-2">
{coverageEnabled && (
<>
<span className="inline-flex items-center gap-1.5">
<span className="size-3 rounded-full bg-emerald-500" /> {t("graph.legendTested")}
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-3 rounded-full bg-neutral-400" /> {t("graph.legendUntested")}
</span>
</>
)}
<span className="inline-flex items-center gap-1.5">
<span className="size-3 rounded-full border border-dashed border-neutral-400 bg-neutral-200" />{" "}
{t("graph.legendOutOfScope")}
</span>
</div>
<p className="text-muted-foreground/80 border-border/60 border-t pt-2 leading-relaxed">{t("graph.hint")}</p>
</div>
<AssetSheet node={selectedAsset} taskId={taskId} onOpenChange={(o) => !o && setSelectedAsset(null)} />
<FoldSheet
fold={selectedFold}
onOpenChange={(o) => !o && setSelectedFoldId(null)}
onShowMore={showMore}
onPick={(n) => {
setSelectedFoldId(null);
setSelectedAsset(n);
}}
/>
</div>
);
}
export function CoverageGraphTab({ taskId, coverageEnabled = true }: { taskId: string; coverageEnabled?: boolean }) {
return (
<Card>
<CardContent className="p-0">
<div className="h-[72vh] w-full overflow-hidden rounded-xl">
<GraphInner taskId={taskId} coverageEnabled={coverageEnabled} />
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,230 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { ArrowDownIcon, ArrowUpIcon, ArrowUpRightIcon, ChevronRightIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { StatusBadge } from "@/components/status-badge";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger } from "@/components/ui/select";
import { api } from "@/lib/api";
import { useStoredSortPreference } from "@/lib/sort-preference";
import type { Finding, FindingStatus } from "@/lib/types";
import { cn } from "@/lib/utils";
type FindingSortField = "time";
const FINDING_SORT_FIELDS: readonly FindingSortField[] = ["time"];
const FINDING_SORT_PREFERENCE_KEY = "artex_task_findings_sort";
function findingLabel(finding: Finding, unclassified: string): string {
if (finding.name?.trim()) return finding.name;
if (finding.vulnclass?.trim()) return finding.vulnclass;
return unclassified;
}
const FINDING_STATUSES: FindingStatus[] = [
"pending",
"in_progress",
"confirmed",
"resolved",
"fixed",
"false_positive",
"ignored",
"duplicate",
"risk_accepted",
];
function Row({
f,
contextTaskId,
onStatus,
}: {
f: Finding;
contextTaskId: string;
onStatus: (f: Finding, next: FindingStatus) => void;
}) {
const t = useTranslations("taskDetail.findings");
const tStatus = useTranslations("status");
const [open, setOpen] = React.useState(false);
return (
<div className="border-b last:border-b-0">
<div className="flex w-full items-center gap-3 px-4 py-3 text-sm hover:bg-accent/40">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex min-w-0 flex-1 items-center gap-3 text-left"
>
<ChevronRightIcon
className={cn("size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-90")}
/>
<StatusBadge domain="severity" value={f.severity} dot />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">{findingLabel(f, t("unclassified"))}</span>
{f.inherited && f.source_task_id && (
<Badge variant="outline" className="shrink-0">
{t("sourceReadonly", { id: f.source_task_id })}
</Badge>
)}
</div>
<span className="truncate text-xs text-muted-foreground">{f.summary}</span>
</div>
</button>
<Badge variant="outline">{t("trafficEvidence", { count: f.traffic_count ?? 0 })}</Badge>
{f.assets && f.assets.length > 0 && (
<div className="hidden shrink-0 flex-wrap justify-end gap-1 sm:flex">
{f.assets.slice(0, 2).map((a) => (
<code
key={a.id}
className="max-w-[10rem] truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
title={`${a.type} · ${a.label}`}
>
{a.label}
</code>
))}
{f.assets.length > 2 && <span className="text-xs text-muted-foreground">+{f.assets.length - 2}</span>}
</div>
)}
{f.finding_id && !f.inherited ? (
<Select value={f.status} onValueChange={(v) => onStatus(f, v as FindingStatus)}>
<SelectTrigger size="sm" className="h-7 w-28 shrink-0 border-none px-1 shadow-none focus-visible:ring-0">
<StatusBadge domain="finding" value={f.status} dot />
</SelectTrigger>
<SelectContent position="popper" align="end">
<SelectGroup>
{FINDING_STATUSES.map((st) => (
<SelectItem key={st} value={st}>
{tStatus(`finding.${st}`)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
) : (
<StatusBadge domain="finding" value={f.status} dot />
)}
<span className="hidden shrink-0 text-xs text-muted-foreground md:block">
{new Date(f.ts).toLocaleString("ko-KR")}
</span>
{f.finding_id && (
<Link
href={
f.inherited
? `/function/findings/detail?id=${f.finding_id}&context_task=${contextTaskId}`
: `/function/findings/detail?id=${f.finding_id}`
}
className="text-muted-foreground hover:text-primary inline-flex shrink-0 items-center gap-0.5 text-xs"
title={t("viewDetail")}
>
{t("detail")}
<ArrowUpRightIcon className="size-3" />
</Link>
)}
</div>
{open && (
<div className="bg-muted/30 px-4 pb-4 pl-11">
<div className="mb-1 text-xs font-medium text-muted-foreground">{t("evidencePoc")}</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 t = useTranslations("taskDetail.findings");
const tStatus = useTranslations("status");
const [findings, setFindings] = React.useState<Finding[]>([]);
const [sortPreference, setSortPreference] = useStoredSortPreference(
FINDING_SORT_PREFERENCE_KEY,
FINDING_SORT_FIELDS,
"time",
"desc",
);
React.useEffect(() => {
let active = true;
const load = () => {
api
.findings(taskId)
.then((fs) => {
if (active) setFindings(fs);
})
.catch(() => {
// Keep the last successful snapshot during transient refresh failures.
});
};
load();
const t = setInterval(load, 3000);
return () => {
active = false;
clearInterval(t);
};
}, [taskId]);
const onStatus = React.useCallback(
async (f: Finding, next: FindingStatus) => {
if (f.inherited || !f.finding_id || next === f.status) return;
const prev = f.status;
setFindings((cur) => cur.map((x) => (x.id === f.id ? { ...x, status: next } : x)));
try {
await api.setFindingStatus(f.finding_id, next);
toast.success(t("markedAs", { label: tStatus(`finding.${next}`) }));
} catch (e) {
setFindings((cur) => cur.map((x) => (x.id === f.id ? { ...x, status: prev } : x)));
toast.error(t("updateFailed", { message: (e as Error).message }));
}
},
[t, tStatus],
);
const items = findings
.filter((f) => f.task_id === taskId || f.inherited)
.sort((a, b) => {
const compared = Date.parse(a.ts) - Date.parse(b.ts);
if (compared !== 0) return sortPreference.direction === "asc" ? compared : -compared;
return b.id.localeCompare(a.id, undefined, { numeric: true });
});
return (
<Card className="overflow-hidden py-0">
<CardContent className="px-0">
<div className="flex items-center border-b px-4 py-2 text-xs text-muted-foreground">
<span className="min-w-0 flex-1">{t("columnFinding")}</span>
<button
type="button"
className="inline-flex items-center gap-1 outline-none focus-visible:underline"
aria-label={t("sortAria", {
order: sortPreference.direction === "asc" ? t("orderAsc") : t("orderDesc"),
})}
onClick={() =>
setSortPreference((current) => ({
field: "time",
direction: current.direction === "asc" ? "desc" : "asc",
}))
}
>
<span>{t("sortTime")}</span>
{sortPreference.direction === "asc" ? (
<ArrowUpIcon className="size-3.5" />
) : (
<ArrowDownIcon className="size-3.5" />
)}
</button>
</div>
{items.map((f) => (
<Row key={f.id} f={f} contextTaskId={taskId} onStatus={onStatus} />
))}
{items.length === 0 && <p className="px-4 py-8 text-center text-sm text-muted-foreground">{t("empty")}</p>}
</CardContent>
</Card>
);
}
@@ -0,0 +1,55 @@
"use client";
import * as React from "react";
import { ExplorationGraph } from "@/components/exploration-graph";
import { Card, CardContent } from "@/components/ui/card";
import { api } from "@/lib/api";
import type { Edge, TaskNode } from "@/lib/types";
export function GraphTab({ taskId }: { taskId: string }) {
const [nodes, setNodes] = React.useState<TaskNode[]>([]);
const [edges, setEdges] = React.useState<Edge[]>([]);
// 上一次图数据的签名:轮询拿到相同数据时跳过 setState,避免整图无谓重建(拖动时
// 才不会被 20s 轮询打断而顿挫)。只取影响渲染的字段。
const sigRef = React.useRef("");
React.useEffect(() => {
let cancelled = false;
sigRef.current = ""; // 换任务:强制下一次刷新
const load = () => {
api
.explorationGraph(taskId)
.then((g) => {
if (cancelled) return;
const ns = g.nodes ?? [];
const es = g.edges ?? [];
const sig = JSON.stringify([
ns.map((n) => [n.id, n.type, n.state, n.priority, n.payload]),
es.map((e) => [e.src, e.dst, e.rel]),
]);
if (sig === sigRef.current) return; // 无变化 → 不重建
sigRef.current = sig;
setNodes(ns);
setEdges(es);
})
.catch(() => {
/* keep last good data */
});
};
load();
const timer = setInterval(load, 20000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [taskId]);
return (
<Card>
<CardContent className="p-0">
<ExplorationGraph nodes={nodes} edges={edges} className="h-[72vh]" />
</CardContent>
</Card>
);
}
@@ -0,0 +1,7 @@
"use client";
import { ApprovalRecords } from "@/components/approval-records";
export function InterceptTab({ taskId }: { taskId: string }) {
return <ApprovalRecords key={taskId} taskId={taskId} />;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,92 @@
"use client";
import * as React from "react";
import { CheckIcon, CopyIcon, FileTextIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Markdown } from "@/components/markdown";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { api } from "@/lib/api";
import { copyText } from "@/lib/utils";
export function ReportTab({ taskId }: { taskId: string }) {
const t = useTranslations("taskDetail.report");
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]);
async function copy() {
if (!report) return;
const ok = await copyText(report);
if (ok) {
setCopied(true);
toast.success(t("copySuccess"));
setTimeout(() => setCopied(false), 1500);
} else {
toast.error(t("copyError"));
}
}
let content: React.ReactNode;
if (loading) {
content = (
<div className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed py-16 text-muted-foreground text-sm">
<FileTextIcon className="size-8 opacity-40" />
{t("loading")}
</div>
);
} else if (report) {
content = (
<div className="max-h-[60vh] overflow-auto rounded-md border bg-muted/20 p-4">
<Markdown text={report} />
</div>
);
} else {
content = (
<div className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed py-16 text-muted-foreground text-sm">
<FileTextIcon className="size-8 opacity-40" />
{t("empty")}
</div>
);
}
return (
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2 text-sm">
<FileTextIcon className="size-4" /> {t("title")}
</CardTitle>
<div className="flex gap-2">
{report && (
<Button size="sm" variant="outline" onClick={copy}>
{copied ? <CheckIcon /> : <CopyIcon />} {t("copy")}
</Button>
)}
</div>
</CardHeader>
<CardContent>{content}</CardContent>
</Card>
);
}
@@ -0,0 +1,175 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { ArrowUpRightIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { FindingRetestPanel } from "@/components/finding-retest-panel";
import { StatusBadge } from "@/components/status-badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { api } from "@/lib/api";
import type { Finding, FindingsPage } from "@/lib/types";
const PAGE_SIZE = 20;
function findingLabel(finding: Finding, unclassified: string) {
return finding.name?.trim() || finding.vulnclass.trim() || unclassified;
}
export function RetestsTab({ taskId }: { taskId: string }) {
const t = useTranslations("taskDetail");
const [page, setPage] = React.useState(1);
const [data, setData] = React.useState<FindingsPage | null>(null);
const [selectedId, setSelectedId] = React.useState("");
const [error, setError] = React.useState("");
const [refreshVersion, setRefreshVersion] = React.useState(0);
const refresh = React.useCallback(() => setRefreshVersion((version) => version + 1), []);
// Only this task's own findings, including those without graph nodes.
// Fetch history only for the selection, and wait for each poll to finish.
React.useEffect(() => {
let disposed = false;
let timer: ReturnType<typeof setTimeout>;
async function load() {
try {
const result = await api.findingsPage({ task: taskId, page, pageSize: PAGE_SIZE, sort: "time" });
if (disposed) return;
const lastPage = Math.max(1, Math.ceil(result.total / PAGE_SIZE));
if (page > lastPage) {
setPage(lastPage);
return;
}
setData(result);
setSelectedId((current) =>
result.items.some((finding) => finding.id === current) ? current : (result.items[0]?.id ?? ""),
);
setError("");
} catch (e) {
if (!disposed) setError((e as Error).message);
} finally {
if (!disposed) timer = setTimeout(() => void load(), 3000);
}
}
void refreshVersion; // Refresh triage immediately after a retest completes.
void load();
return () => {
disposed = true;
clearTimeout(timer);
};
}, [taskId, page, refreshVersion]);
const loaded = data?.page === page;
const findings = loaded ? data.items : [];
const selected = findings.find((finding) => finding.id === selectedId);
return (
<div className="flex flex-col gap-4">
{error ? (
<Alert variant="destructive">
<AlertDescription>
{t("retests.loadError", { error })}
<Button variant="outline" size="sm" onClick={refresh}>
{t("retests.retry")}
</Button>
</AlertDescription>
</Alert>
) : null}
<div className="grid items-start gap-4 lg:grid-cols-[minmax(16rem,22rem)_minmax(0,1fr)]">
<Card className="min-w-0">
<CardHeader>
<CardTitle>
{t("retests.selectTitle")}
{data ? ` · ${data.total}` : ""}
</CardTitle>
<CardDescription>{t("retests.selectDesc")}</CardDescription>
</CardHeader>
<CardContent className="flex max-h-[32rem] flex-col overflow-y-auto">
{!loaded && !error ? <Skeleton className="h-24 w-full" /> : null}
{findings.map((finding, index) => (
<React.Fragment key={finding.id}>
{index > 0 ? <Separator className="my-1" /> : null}
<Button
variant={finding.id === selectedId ? "secondary" : "ghost"}
className="h-auto w-full shrink-0 flex-col items-start gap-2 whitespace-normal py-3 text-left"
aria-label={t("retests.selectAria", { name: findingLabel(finding, t("findings.unclassified")) })}
aria-pressed={finding.id === selectedId}
onClick={() => setSelectedId(finding.id)}
>
<span className="line-clamp-2 break-words">{findingLabel(finding, t("findings.unclassified"))}</span>
<span className="flex flex-wrap items-center gap-2">
<StatusBadge domain="severity" value={finding.severity} dot />
<StatusBadge domain="finding" value={finding.status} dot />
</span>
</Button>
</React.Fragment>
))}
{loaded && findings.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyTitle>{t("retests.emptyTitle")}</EmptyTitle>
<EmptyDescription>{t("retests.emptyDesc")}</EmptyDescription>
</EmptyHeader>
</Empty>
) : null}
</CardContent>
{data && data.total > PAGE_SIZE ? (
<CardFooter className="justify-between gap-2">
<Button
variant="outline"
size="icon-sm"
aria-label={t("retests.prevPage")}
disabled={page === 1}
onClick={() => setPage(page - 1)}
>
<ChevronLeftIcon />
</Button>
<span className="text-muted-foreground text-xs">
{t("retests.pageIndicator", { page, total: Math.ceil(data.total / PAGE_SIZE) })}
</span>
<Button
variant="outline"
size="icon-sm"
aria-label={t("retests.nextPage")}
disabled={page * PAGE_SIZE >= data.total}
onClick={() => setPage(page + 1)}
>
<ChevronRightIcon />
</Button>
</CardFooter>
) : null}
</Card>
{selected ? (
<div className="flex min-w-0 flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-start justify-between gap-2">
<h2 className="min-w-0 flex-1 break-words font-medium">
{findingLabel(selected, t("findings.unclassified"))}
</h2>
<Button asChild variant="ghost" size="sm">
<Link href={`/function/findings/detail?id=${selected.finding_id || selected.id}`}>
{t("retests.findingDetail")} <ArrowUpRightIcon data-icon="inline-end" />
</Link>
</Button>
</div>
<p className="line-clamp-3 break-words text-muted-foreground text-sm">{selected.summary}</p>
</div>
<FindingRetestPanel
key={selected.id}
findingId={selected.finding_id || selected.id}
findingName={findingLabel(selected, t("findings.unclassified"))}
onCompleted={refresh}
/>
</div>
) : null}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,462 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { ArchiveIcon, ArrowLeftIcon, BrainIcon, CheckIcon, CircleAlertIcon, PauseIcon, PlayIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { StatusBadge } from "@/components/status-badge";
import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { api } from "@/lib/api";
import type { LLMProfile, Task } from "@/lib/types";
import { AssetsTab } from "./_tabs/assets-tab";
import { BroadcastTab } from "./_tabs/broadcast-tab";
import { CoverageGraphTab } from "./_tabs/coverage-graph-tab";
import { FindingsTab } from "./_tabs/findings-tab";
import { GraphTab } from "./_tabs/graph-tab";
import { InterceptTab } from "./_tabs/intercept-tab";
import { OverviewTab } from "./_tabs/overview-tab";
import { ReportTab } from "./_tabs/report-tab";
import { RetestsTab } from "./_tabs/retests-tab";
import { SessionsTab } from "./_tabs/sessions-tab";
const TAB_VALUES = [
"sessions",
"overview",
"graph",
"broadcast",
"findings",
"retests",
"assets",
"coverage",
"intercept",
"report",
] as const;
function taskProfileIDs(task: Task): string[] {
if (task.llm_profile_ids && task.llm_profile_ids.length > 0) {
return task.llm_profile_ids.map(String);
}
return task.llm_profile_id ? [String(task.llm_profile_id)] : [];
}
function TaskLLMControl({ task, profiles, onUpdated }: { task: Task; profiles: LLMProfile[]; onUpdated: () => void }) {
const t = useTranslations("taskDetail.llm");
const [open, setOpen] = React.useState(false);
const [profileIDs, setProfileIDs] = React.useState<string[]>(() => taskProfileIDs(task));
const [activeProfileID, setActiveProfileID] = React.useState(
task.active_llm_profile_id ? String(task.active_llm_profile_id) : (taskProfileIDs(task)[0] ?? ""),
);
const [saving, setSaving] = React.useState(false);
const popoverContentRef = React.useRef<HTMLDivElement>(null);
const chain = taskProfileIDs(task);
const exhausted = task.llm_failover_state === "chain_exhausted";
// 任何状态都可以改链,终态也不例外:任务结束后主 Agent 对话仍走这条链,
// 链上模型出问题时必须能换掉,否则已完成任务就没法继续交互。
const terminal = ["done", "failed", "timeout"].includes(task.status);
// A null active profile on an exhausted, non-empty chain is a persisted end
// cursor. Keep the status display honest; choosing the first profile is only
// the editor's reset draft and does not mean it is currently active.
let activeID = chain[0] ?? "";
if (exhausted) activeID = "";
if (task.active_llm_profile_id) activeID = String(task.active_llm_profile_id);
const activeProfile = profiles.find((profile) => profile.id === activeID);
const currentLabel = exhausted
? t("exhausted")
: (activeProfile?.name ?? (activeID ? t("profileRef", { id: activeID }) : t("followDefault")));
const activeIndex = chain.indexOf(activeID);
const backupCount = activeIndex >= 0 ? Math.max(0, chain.length - activeIndex - 1) : 0;
const currentTitle = [
currentLabel,
activeProfile?.model,
backupCount > 0 ? t("backupCount", { count: backupCount }) : "",
]
.filter(Boolean)
.join(" · ");
let editorDescription = t("editorDescription");
if (terminal) editorDescription = t("editorDescriptionTerminal");
let saveLabel = t("save");
if (exhausted) saveLabel = t("saveReset");
if (saving) saveLabel = t("saving");
const syncDraft = React.useCallback(() => {
const next = taskProfileIDs(task);
setProfileIDs(next);
setActiveProfileID(task.active_llm_profile_id ? String(task.active_llm_profile_id) : (next[0] ?? ""));
}, [task]);
const handleOpenChange = (next: boolean) => {
setOpen(next);
if (next) syncDraft();
};
const handleProfileIDsChange = (next: string[]) => {
setProfileIDs(next);
setActiveProfileID((current) => (next.includes(current) ? current : (next[0] ?? "")));
};
const save = async () => {
setSaving(true);
try {
const result = await api.updateTaskLLMProfiles(
task.id,
profileIDs.map(Number),
activeProfileID ? Number(activeProfileID) : undefined,
);
if (result.switch_event) {
toast.info(result.switch_event.summary, { id: `task-${task.id}-llm-${result.switch_event.seq}` });
} else {
toast.success(
result.reopened_intents > 0 ? t("updatedWithReopened", { count: result.reopened_intents }) : t("updated"),
);
}
setOpen(false);
onUpdated();
} catch (error) {
toast.error(t("updateFailed", { message: (error as Error).message }));
} finally {
setSaving(false);
}
};
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
size="sm"
variant={exhausted ? "destructive" : "outline"}
aria-label={t("controlAria")}
title={currentTitle}
>
<BrainIcon data-icon="inline-start" />
<span className="hidden max-w-36 truncate lg:inline">{currentLabel}</span>
{backupCount > 0 && <span className="hidden text-muted-foreground xl:inline">+{backupCount}</span>}
</Button>
</PopoverTrigger>
<PopoverContent ref={popoverContentRef} align="start" className="w-[min(28rem,calc(100vw-2rem))] gap-4 p-4">
<PopoverHeader>
<PopoverTitle>{t("chainTitle")}</PopoverTitle>
<PopoverDescription>{editorDescription}</PopoverDescription>
</PopoverHeader>
{exhausted && (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>{t("exhaustedTitle")}</AlertTitle>
<AlertDescription>{task.llm_failover_reason ?? t("exhaustedDesc")}</AlertDescription>
</Alert>
)}
<TaskLLMProfileChain
profiles={profiles}
value={profileIDs}
onValueChange={handleProfileIDsChange}
activeProfileId={activeProfileID}
onActiveProfileChange={setActiveProfileID}
inputId="task-llm-profiles"
disabled={saving}
portalContainer={popoverContentRef}
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(false)}>
{t("close")}
</Button>
<Button type="button" size="sm" onClick={save} disabled={saving}>
{saving && <Spinner data-icon="inline-start" />}
{saveLabel}
</Button>
</div>
</PopoverContent>
</Popover>
);
}
function TaskDetailInner() {
const t = useTranslations("taskDetail");
const router = useRouter();
const searchParams = useSearchParams();
const id = searchParams.get("id") ?? "";
const [task, setTask] = React.useState<Task | null>(null);
const [paused, setPaused] = React.useState(false);
const [loaded, setLoaded] = React.useState(false);
const [tab, setTab] = React.useState("sessions");
const [interceptPendingCount, setInterceptPendingCount] = React.useState(0);
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
const [archiving, setArchiving] = React.useState(false);
React.useEffect(() => {
api
.llmProfiles()
.then(setProfiles)
.catch(() => setProfiles([]));
}, []);
React.useEffect(() => {
let alive = true;
const load = () =>
api
.interceptTask(id)
.then((rows) => {
if (alive) setInterceptPendingCount(rows.filter((r) => r.status === "pending").length);
})
.catch(() => {
// Polling is best-effort; the next interval retries automatically.
});
void load();
const t = setInterval(load, 5000);
return () => {
alive = false;
clearInterval(t);
};
}, [id]);
const taskLoadInFlight = React.useRef<string | null>(null);
const load = React.useCallback(() => {
if (taskLoadInFlight.current === id) return;
taskLoadInFlight.current = id;
Promise.all([api.task(id), api.stats(id).catch(() => null)])
.then(([task, s]) => {
if (taskLoadInFlight.current !== id) return;
const base = { ...task };
const at = s?.active_task;
if (at) {
base.in_flight = at.in_flight;
base.goals_total = at.goals_total;
base.goals_met = at.goals_met;
base.engine_mode = s?.engine_mode ?? at.engine_mode;
base.paused = at.paused;
}
setTask(base);
setPaused(at?.paused ?? base.paused ?? false);
})
.catch(() => {
// Keep the last rendered task state during a transient poll failure.
})
.finally(() => {
if (taskLoadInFlight.current === id) {
taskLoadInFlight.current = null;
setLoaded(true);
}
});
}, [id]);
React.useEffect(() => {
load();
const timer = setInterval(load, 5000);
return () => clearInterval(timer);
}, [load]);
async function togglePause() {
if (task && ["done", "failed", "timeout"].includes(task.status)) return;
const next = !paused;
try {
await api.controlTask(id, next ? "pause" : "resume");
setPaused(next);
toast.success(next ? t("control.paused") : t("control.resumed"));
} catch (e) {
toast.error(t("control.failed", { message: (e as Error).message }));
}
}
async function archiveTask() {
if (!task || archiving) return;
setArchiving(true);
try {
await api.archiveTask(task.id);
toast.success(t("archive.queued"));
router.push("/function/tasks");
} catch (error) {
toast.error(t("archive.failed", { message: (error as Error).message }));
setArchiving(false);
}
}
if (!task) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-10 text-center">
<p className="text-muted-foreground">{loaded ? t("header.notFound", { id }) : t("header.loading")}</p>
{loaded && (
<Button asChild variant="outline">
<Link href="/function/tasks">
<ArrowLeftIcon /> {t("header.backToList")}
</Link>
</Button>
)}
</div>
);
}
const completed = task.status === "done";
const terminal = ["done", "failed", "timeout"].includes(task.status);
const archiveLifecycleEligible = terminal || paused || task.status === "paused";
const canArchive = archiveLifecycleEligible && !task.archive_blocked_by_task_id;
let archiveDisabledReason = task.queued ? t("archive.disabledQueued") : t("archive.disabledRunning");
if (archiveLifecycleEligible && task.archive_blocked_by_task_id) {
archiveDisabledReason = t("archive.disabledInherited", { id: task.archive_blocked_by_task_id });
}
const engineMode = paused ? "paused" : (task.engine_mode ?? "idle");
let controlVariant: "default" | "secondary" | "outline" = "outline";
let controlIcon = <PauseIcon data-icon="inline-start" />;
let controlLabel = t("control.pause");
if (terminal) {
controlVariant = "secondary";
controlIcon = <CheckIcon data-icon="inline-start" />;
controlLabel = completed ? t("control.completed") : t("control.ended");
} else if (paused) {
controlVariant = "default";
controlIcon = <PlayIcon data-icon="inline-start" />;
controlLabel = t("control.resume");
}
const archiveTrigger = (
<Button
size="icon-sm"
variant="ghost"
disabled={!canArchive || archiving}
aria-label={canArchive ? t("archive.triggerAria") : archiveDisabledReason}
>
{archiving ? <Spinner /> : <ArchiveIcon />}
</Button>
);
return (
<Tabs value={tab} onValueChange={setTab} className="flex flex-1 flex-col gap-0">
{/* Top fixed area */}
<header className="sticky top-0 z-10 flex flex-col gap-2 border-b bg-background/95 px-4 py-2.5 backdrop-blur lg:px-6">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Button asChild variant="ghost" size="icon" className="size-7">
<Link href="/function/tasks">
<ArrowLeftIcon />
</Link>
</Button>
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold" title={task.name || task.description}>
{task.name || task.description}
</h1>
<code className="hidden rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground sm:inline">
{task.id}
</code>
<Separator orientation="vertical" className="mx-1 hidden h-4 sm:block" />
<TaskLLMControl task={task} profiles={profiles} onUpdated={load} />
<StatusBadge domain={terminal ? "task" : "engine"} value={terminal ? task.status : engineMode} dot />
{canArchive ? (
<AlertDialog>
<AlertDialogTrigger asChild>{archiveTrigger}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("archive.dialogTitle", { id: task.id })}</AlertDialogTitle>
<AlertDialogDescription>{t("archive.dialogDesc")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("archive.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => void archiveTask()}>{t("archive.confirm")}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">{archiveTrigger}</span>
</TooltipTrigger>
<TooltipContent>{archiveDisabledReason}</TooltipContent>
</Tooltip>
)}
<Button size="sm" variant={controlVariant} onClick={togglePause} disabled={terminal}>
{controlIcon}
{controlLabel}
</Button>
</div>
<p className="truncate text-xs text-muted-foreground">{task.goal}</p>
{/* Tabs */}
<div className="no-scrollbar min-w-0 overflow-x-auto">
<TabsList variant="default" className="min-w-max">
{TAB_VALUES.map((value) => (
<TabsTrigger key={value} value={value}>
{t(`tabs.${value}`)}
{value === "intercept" && interceptPendingCount > 0 && (
<span className="ml-1.5 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-semibold leading-none text-white">
{interceptPendingCount > 99 ? "99+" : interceptPendingCount}
</span>
)}
</TabsTrigger>
))}
</TabsList>
</div>
</header>
{/* Tab content */}
<div className="flex-1 p-4 lg:p-6">
<TabsContent value="sessions" className="mt-0">
<SessionsTab taskId={id} />
</TabsContent>
<TabsContent value="overview" className="mt-0">
<OverviewTab taskId={id} />
</TabsContent>
<TabsContent value="graph" className="mt-0">
<GraphTab taskId={id} />
</TabsContent>
<TabsContent value="broadcast" className="mt-0">
<BroadcastTab taskId={id} />
</TabsContent>
<TabsContent value="findings" className="mt-0">
<FindingsTab taskId={id} />
</TabsContent>
<TabsContent value="retests" className="mt-0">
<RetestsTab key={id} taskId={id} />
</TabsContent>
<TabsContent value="assets" className="mt-0">
<AssetsTab taskId={id} />
</TabsContent>
<TabsContent value="coverage" className="mt-0">
<CoverageGraphTab taskId={id} coverageEnabled={task?.coverage_enabled !== false} />
</TabsContent>
<TabsContent value="intercept" className="mt-0">
<InterceptTab taskId={id} />
</TabsContent>
<TabsContent value="report" className="mt-0">
<ReportTab taskId={id} />
</TabsContent>
</div>
</Tabs>
);
}
// useSearchParams must sit under a Suspense boundary for static export.
export default function TaskDetailPage() {
return (
<React.Suspense fallback={null}>
<TaskDetailInner />
</React.Suspense>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,836 @@
"use client";
import * as React from "react";
import {
ArrowDownWideNarrowIcon,
ArrowUpNarrowWideIcon,
ChevronLeftIcon,
ChevronRightIcon,
EraserIcon,
FilterXIcon,
ListChecksIcon,
Loader2Icon,
RadioTowerIcon,
SearchIcon,
Trash2Icon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { HttpCodeBlock } from "@/components/http-code-block";
import { LinkTrafficDialog } from "@/components/link-traffic-dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { SortableHead } from "@/components/ui/sortable-head";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { api } from "@/lib/api";
import { useStoredSortPreference } from "@/lib/sort-preference";
import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } from "@/lib/types";
import { cn } from "@/lib/utils";
function fmtTime(ts: string) {
return new Date(ts).toLocaleString("ko-KR", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function fmtBytes(n: number) {
if (n <= 0) return "0 B";
// GB matters for the reclaimed-space figure a full purge reports; a capture-heavy
// instance can hand back several.
const units = ["B", "KB", "MB", "GB"];
const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1);
const v = n / 1024 ** i;
return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`;
}
function statusTone(status: number) {
if (status >= 500) return "text-red-500";
if (status >= 400) return "text-amber-500";
if (status >= 300) return "text-blue-500";
if (status >= 200) return "text-emerald-500";
return "text-muted-foreground";
}
function MethodBadge({ method }: { method: string }) {
return <Badge className="shrink-0 font-mono">{method}</Badge>;
}
// Older captures may predate Host persistence because net/http keeps Host
// outside Request.Header. Fill it for display while newly recorded traffic is
// fixed at the recorder layer as well.
function requestWithHost(raw: string, exchange: TrafficExchange): string {
if (!raw.trim() || /^host\s*:/im.test(raw)) return raw;
let host = exchange.host;
try {
host = new URL(exchange.url).host || host;
} catch {
// Relative or legacy URLs fall back to the indexed host.
}
const newline = raw.includes("\r\n") ? "\r\n" : "\n";
const firstLineEnd = raw.indexOf(newline);
if (firstLineEnd < 0) return `${raw}${newline}Host: ${host}`;
return `${raw.slice(0, firstLineEnd + newline.length)}Host: ${host}${newline}${raw.slice(firstLineEnd + newline.length)}`;
}
// Fixed method set (server filters exact-match); avoids deriving options from a
// single page, which would only ever list the methods on that page.
const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
const PAGE_SIZES = [25, 50, 100, 200];
type HostCountSortDirection = "asc" | "desc";
// Server-sortable columns. The list is sent to the backend verbatim as `sort`,
// which whitelists these same names, so keep them in sync with traffic.Page.
const SORT_FIELDS = ["ts", "status", "resp_len"] as const;
type SortField = (typeof SORT_FIELDS)[number];
const SORT_STORAGE_KEY = "traffic-sort";
// Status-class buckets for the filter dropdown; the value is sent as `status`,
// which the backend reads as either an exact code or an "Nxx" class band.
const STATUS_BUCKETS = ["2xx", "3xx", "4xx", "5xx"];
export default function TrafficPage() {
const t = useTranslations("traffic");
const tp = useTranslations("pagination");
const [selectedFlows, setSelectedFlows] = React.useState<Set<string>>(() => new Set());
const [linking, setLinking] = React.useState(false);
const [page, setPage] = React.useState(0);
const [size, setSize] = React.useState(50);
const [host, setHost] = React.useState(""); // raw host input
const [hostQ, setHostQ] = React.useState(""); // debounced → server
const [query, setQuery] = React.useState(""); // raw free-text input
const [queryQ, setQueryQ] = React.useState(""); // debounced → server
const [method, setMethod] = React.useState("all");
// Advanced filters (issue #177): response-body content, path, status class and
// response-size range. Text inputs are debounced like host/query; the status
// select applies immediately.
const [body, setBody] = React.useState("");
const [bodyQ, setBodyQ] = React.useState("");
const [path, setPath] = React.useState("");
const [pathQ, setPathQ] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState("all");
const [respMin, setRespMin] = React.useState("");
const [respMinQ, setRespMinQ] = React.useState("");
const [respMax, setRespMax] = React.useState("");
const [respMaxQ, setRespMaxQ] = React.useState("");
const [sort, setSort] = useStoredSortPreference<SortField>(SORT_STORAGE_KEY, SORT_FIELDS, "ts", "desc");
const [traffic, setTraffic] = React.useState<TrafficResp | null>(null);
const [selected, setSelected] = React.useState<TrafficExchange | null>(null);
const [detail, setDetail] = React.useState<TrafficDetail | null>(null);
const [detailLoading, setDetailLoading] = React.useState(false);
const [hosts, setHosts] = React.useState<TrafficHost[]>([]); // target picker
const [selectedHosts, setSelectedHosts] = React.useState<string[]>([]); // checked in picker
const [pickerOpen, setPickerOpen] = React.useState(false);
const [hostCountSortDirection, setHostCountSortDirection] = React.useState<HostCountSortDirection>("desc");
const [deleteMode, setDeleteMode] = React.useState<"filter" | "selected" | "all" | null>(null); // null = dialog closed
const [deleting, setDeleting] = React.useState(false);
const [reloadTick, setReloadTick] = React.useState(0); // manual refetch trigger
// Debounce both filters so we don't refetch on every keystroke.
React.useEffect(() => {
const t = setTimeout(() => setHostQ(host.trim()), 300);
return () => clearTimeout(t);
}, [host]);
React.useEffect(() => {
const t = setTimeout(() => setQueryQ(query.trim()), 300);
return () => clearTimeout(t);
}, [query]);
React.useEffect(() => {
const t = setTimeout(() => setBodyQ(body.trim()), 300);
return () => clearTimeout(t);
}, [body]);
React.useEffect(() => {
const t = setTimeout(() => setPathQ(path.trim()), 300);
return () => clearTimeout(t);
}, [path]);
React.useEffect(() => {
const t = setTimeout(() => setRespMinQ(respMin.trim()), 300);
return () => clearTimeout(t);
}, [respMin]);
React.useEffect(() => {
const t = setTimeout(() => setRespMaxQ(respMax.trim()), 300);
return () => clearTimeout(t);
}, [respMax]);
const hasAdvancedFilter = Boolean(bodyQ || pathQ || respMinQ || respMaxQ) || statusFilter !== "all";
const resetAdvancedFilters = () => {
setBody("");
setBodyQ("");
setPath("");
setPathQ("");
setStatusFilter("all");
setRespMin("");
setRespMinQ("");
setRespMax("");
setRespMaxQ("");
};
// Any filter/size/sort change resets to the first page.
// biome-ignore lint/correctness/useExhaustiveDependencies: these values intentionally trigger a page reset.
React.useEffect(() => {
setPage(0);
}, [hostQ, queryQ, method, size, bodyQ, pathQ, statusFilter, respMinQ, respMaxQ, sort]);
// Load the current page. Auto-refresh only on page 0 (newest) so paging back
// through history isn't yanked out from under the user.
// biome-ignore lint/correctness/useExhaustiveDependencies: reloadTick is an explicit manual-refetch trigger.
React.useEffect(() => {
let alive = true;
const load = () => {
api
.traffic(page, size, hostQ, method, queryQ, {
body: bodyQ,
path: pathQ,
status: statusFilter,
respMin: respMinQ,
respMax: respMaxQ,
sort: sort.field,
order: sort.direction,
})
.then((r) => {
if (alive) setTraffic(r);
})
.catch(() => {
// Keep the last successful snapshot during transient refresh failures.
});
api
.trafficHosts()
.then((r) => {
if (alive) setHosts(r.hosts ?? []);
})
.catch(() => {
// Keep the last successful host list during transient refresh failures.
});
};
load();
const t = setInterval(() => {
if (page !== 0) return; // only auto-refresh the newest page
load();
}, 5000);
return () => {
alive = false;
clearInterval(t);
};
}, [page, size, hostQ, method, queryQ, bodyQ, pathQ, statusFilter, respMinQ, respMaxQ, sort, reloadTick]);
// Delete traffic for the current host filter (substring) or the checked
// hosts (exact batch), then refetch.
const allSelected = hosts.length > 0 && hosts.every((h) => selectedHosts.includes(h.host));
const sortedHosts = React.useMemo(
() =>
[...hosts].sort((a, b) => {
const countOrder = hostCountSortDirection === "asc" ? a.count - b.count : b.count - a.count;
return countOrder || a.host.localeCompare(b.host);
}),
[hosts, hostCountSortDirection],
);
// 필터 없는 전체 삭제는 "비우기", 대상 한정 삭제는 "삭제"로 쓴다. 다이얼로그 제목과
// 확인 버튼 문구가 모두 어느 쪽인지에 따라 갈린다.
const deleteTitle = deleteMode
? {
all: t("dialog.titlePurge"),
selected: t("dialog.titleSelected", { count: selectedHosts.length }),
filter: t("dialog.titleFilter"),
}[deleteMode]
: "";
// `reclaimed` only comes back from the full purge; the host-scoped deletions
// report the row count alone.
const requestDelete = (mode: "filter" | "selected" | "all"): Promise<{ deleted: number; reclaimed?: number }> => {
if (mode === "all") return api.trafficDeleteAll();
if (mode === "selected") return api.trafficDeleteHosts(selectedHosts);
return api.trafficDeleteHost(hostQ);
};
const confirmDelete = () => {
if (!deleteMode) return;
setDeleting(true);
const mode = deleteMode;
requestDelete(mode)
.then((r) => {
setDeleteMode(null);
setSelected(null);
setDetail(null);
if (mode !== "filter") {
setSelectedHosts([]);
setPickerOpen(false);
}
if (mode === "all") {
// Reclaimed space is the whole point of compacting an emptied index, so say so.
const reclaimed = r.reclaimed ?? 0;
toast.success(
reclaimed > 0
? t("toast.purgedWithSpace", { deleted: r.deleted, space: fmtBytes(reclaimed) })
: t("toast.purged", { deleted: r.deleted }),
);
}
setPage(0);
setReloadTick((t) => t + 1);
})
.catch((e) => {
// Keep the confirmation open so the user can retry a failed deletion.
if (mode === "all") toast.error(t("toast.purgeFailed", { msg: (e as Error).message }));
})
.finally(() => setDeleting(false));
};
// Lazy-load the raw request/response for the selected exchange.
React.useEffect(() => {
if (!selected) {
setDetail(null);
return;
}
let alive = true;
setDetailLoading(true);
setDetail(null);
api
.trafficExchange(selected.id)
.then((d) => {
if (alive) setDetail(d);
})
.catch(() => {
if (alive) setDetail({ req: t("detail.loadFailed"), resp: "" });
})
.finally(() => {
if (alive) setDetailLoading(false);
});
return () => {
alive = false;
};
}, [selected, t]);
// Toggle direction when re-clicking the active column, else sort the new column
// newest/largest-first.
const toggleSort = (field: SortField) =>
setSort((prev) =>
prev.field === field
? { field, direction: prev.direction === "asc" ? "desc" : "asc" }
: { field, direction: "desc" },
);
const exchanges = React.useMemo(() => traffic?.exchanges ?? [], [traffic]);
const total = traffic?.total ?? exchanges.length;
const pageCount = Math.max(1, Math.ceil(total / size));
const rangeStart = total === 0 ? 0 : page * size + 1;
const rangeEnd = page * size + exchanges.length;
return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground text-sm">{t("subtitle")}</p>
</div>
<div className="flex items-center gap-4 text-sm">
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium",
traffic?.enabled
? "border-emerald-500/20 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
: "border-transparent bg-muted text-muted-foreground",
)}
>
<RadioTowerIcon className="size-3.5" />
{traffic?.enabled ? t("recording") : t("stopped")}
</span>
{traffic?.proxy && <span className="font-mono text-xs text-muted-foreground">{traffic.proxy}</span>}
<span className="text-xs text-muted-foreground">
{t.rich("total", {
n: () => <span className="tabular-nums">{traffic?.count ?? 0}</span>,
})}
</span>
</div>
</div>
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-2">
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
<ListChecksIcon className="size-3.5" />
{selectedHosts.length > 0
? t("picker.selectTargetCount", { count: selectedHosts.length })
: t("picker.selectTarget")}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[calc(100vw-2rem)] p-0 data-open:animate-none data-closed:animate-none sm:w-80"
align="start"
collisionPadding={16}
>
<div className="flex items-center justify-between border-b px-3 py-2">
<span className="text-xs font-medium text-muted-foreground">{t("picker.batchByTarget")}</span>
<div className="flex items-center gap-1">
{hosts.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setHostCountSortDirection((current) => (current === "desc" ? "asc" : "desc"))}
aria-label={
hostCountSortDirection === "desc" ? t("picker.sortAriaDesc") : t("picker.sortAriaAsc")
}
>
{hostCountSortDirection === "desc" ? <ArrowDownWideNarrowIcon /> : <ArrowUpNarrowWideIcon />}
</Button>
</TooltipTrigger>
<TooltipContent>
{hostCountSortDirection === "desc" ? t("picker.sortTipDesc") : t("picker.sortTipAsc")}
</TooltipContent>
</Tooltip>
)}
{hosts.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => setSelectedHosts(allSelected ? [] : hosts.map((h) => h.host))}
>
{allSelected ? t("picker.deselectAll") : t("picker.selectAll")}
</Button>
)}
</div>
</div>
<div className="max-h-64 overflow-y-auto">
{hosts.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">{t("picker.empty")}</div>
) : (
sortedHosts.map((h, index) => (
<label
key={h.host}
htmlFor={`traffic-host-${index}`}
className="flex cursor-pointer items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent"
>
<Checkbox
id={`traffic-host-${index}`}
checked={selectedHosts.includes(h.host)}
onCheckedChange={() =>
setSelectedHosts((prev) =>
prev.includes(h.host) ? prev.filter((x) => x !== h.host) : [...prev, h.host],
)
}
/>
<span className="truncate font-mono">{h.host}</span>
<span className="ml-auto shrink-0 tabular-nums text-muted-foreground">{h.count}</span>
</label>
))
)}
</div>
<div className="border-t p-2">
<Button
variant="destructive"
size="sm"
className="w-full"
disabled={selectedHosts.length === 0}
onClick={() => {
setDeleteMode("selected");
setPickerOpen(false);
}}
>
{t("picker.deleteSelected", { count: selectedHosts.length })}
</Button>
</div>
</PopoverContent>
</Popover>
<div className="relative w-48">
<Input placeholder="host…" value={host} onChange={(e) => setHost(e.target.value)} className="h-8" />
</div>
<Button
variant="destructive"
size="sm"
className="h-8"
disabled={!hostQ || deleting}
title={hostQ ? undefined : t("toolbar.hostHint")}
onClick={() => setDeleteMode("filter")}
>
<Trash2Icon className="size-3.5" />
{t("toolbar.deleteHost")}
</Button>
{/* 두 번째 파괴적 버튼이 아니라 아웃라인으로 둔다. 이 버튼은 모든 필터를 무시하므로
"이 대상 삭제" 버튼 바로 옆에서 한 번의 오클릭처럼 보이면 안 된다. */}
<Button
variant="outline"
size="sm"
className="h-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={!traffic?.count || deleting}
title={traffic?.count ? t("toolbar.purgeTitle") : t("toolbar.purgeEmptyTitle")}
onClick={() => setDeleteMode("all")}
>
<EraserIcon className="size-3.5" />
{t("toolbar.purgeAll")}
</Button>
<div className="relative max-w-sm flex-1">
<SearchIcon className="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={t("toolbar.search")}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-8 pl-8"
/>
</div>
<Select value={method} onValueChange={setMethod}>
<SelectTrigger size="sm" className="w-32">
<SelectValue placeholder={t("toolbar.method")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("toolbar.allMethods")}</SelectItem>
{METHODS.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={String(size)} onValueChange={(v) => setSize(Number(v))}>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((n) => (
<SelectItem key={n} value={String(n)}>
{n}
{tp("perPage")}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
<span className="tabular-nums">
{rangeStart}–{rangeEnd} / {total}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<ChevronLeftIcon />
</Button>
<span className="tabular-nums">
{page + 1} / {pageCount}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page + 1 >= pageCount}
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
>
<ChevronRightIcon />
</Button>
</div>
</div>
{/* Advanced filters (issue #177): narrow 660k+ exchanges down to the one packet. */}
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 px-2 py-1.5">
<span className="pl-1 text-xs font-medium text-muted-foreground">{t("filters.advanced")}</span>
<div className="relative w-56">
<Input
placeholder={t("filters.body")}
value={body}
onChange={(e) => setBody(e.target.value)}
className="h-8"
/>
</div>
<div className="relative w-52">
<Input
placeholder={t("filters.path")}
value={path}
onChange={(e) => setPath(e.target.value)}
className="h-8"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger size="sm" className="w-28">
<SelectValue placeholder={t("filters.status")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filters.allStatus")}</SelectItem>
{STATUS_BUCKETS.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>{t("filters.respLen")}</span>
<Input
type="number"
min={0}
placeholder={t("filters.min")}
value={respMin}
onChange={(e) => setRespMin(e.target.value)}
className="h-8 w-24"
/>
<span>–</span>
<Input
type="number"
min={0}
placeholder={t("filters.max")}
value={respMax}
onChange={(e) => setRespMax(e.target.value)}
className="h-8 w-24"
/>
</div>
{hasAdvancedFilter ? (
<Button variant="ghost" size="sm" className="h-8" onClick={resetAdvancedFilters}>
<FilterXIcon className="size-3.5" />
{t("filters.clear")}
</Button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">{t("selection.selected", { count: selectedFlows.size })}</span>
<Button variant="outline" size="sm" disabled={selectedFlows.size === 0} onClick={() => setLinking(true)}>
{t("selection.linkToVuln")}
</Button>
{selectedFlows.size > 0 ? (
<Button variant="ghost" size="sm" onClick={() => setSelectedFlows(new Set())}>
{t("selection.clear")}
</Button>
) : null}
</div>
{/* History table */}
<div className="flex h-[calc(100vh-15rem)] min-h-0 flex-col">
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
<div className="min-h-0 flex-1 overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead className="w-10">
<Checkbox
aria-label={t("table.selectPage")}
checked={exchanges.length > 0 && exchanges.every((e) => selectedFlows.has(e.id))}
onCheckedChange={(checked) =>
setSelectedFlows((previous) => {
const next = new Set(previous);
for (const e of exchanges) {
if (checked === true) next.add(e.id);
else next.delete(e.id);
}
return next;
})
}
/>
</TableHead>
<SortableHead
field="ts"
label={t("table.time")}
activeField={sort.field}
direction={sort.direction}
onSort={toggleSort}
className="w-36"
/>
<TableHead className="w-44">host</TableHead>
<TableHead className="w-20">{t("table.method")}</TableHead>
<TableHead>URL</TableHead>
<SortableHead
field="status"
label={t("table.status")}
activeField={sort.field}
direction={sort.direction}
onSort={toggleSort}
className="w-20"
/>
<TableHead className="w-36">content-type</TableHead>
<SortableHead
field="resp_len"
label={t("table.respLen")}
activeField={sort.field}
direction={sort.direction}
onSort={toggleSort}
align="right"
className="w-24 text-right"
/>
</TableRow>
</TableHeader>
<TableBody>
{exchanges.map((e) => (
<TableRow
key={e.id}
className={cn("cursor-pointer", selected?.id === e.id && "bg-accent hover:bg-accent")}
onClick={() => setSelected(e)}
>
<TableCell>
<Checkbox
aria-label={t("table.selectRow", { id: e.id })}
checked={selectedFlows.has(e.id)}
onClick={(event) => event.stopPropagation()}
onCheckedChange={(checked) =>
setSelectedFlows((previous) => {
const next = new Set(previous);
if (checked === true) next.add(e.id);
else next.delete(e.id);
return next;
})
}
/>
</TableCell>
<TableCell className="text-xs text-muted-foreground tabular-nums">{fmtTime(e.ts)}</TableCell>
<TableCell className="font-mono text-xs">{e.host}</TableCell>
<TableCell>
<MethodBadge method={e.method} />
</TableCell>
<TableCell className="max-w-0">
<span className="block truncate font-mono text-xs">{e.url}</span>
</TableCell>
<TableCell>
<span className={cn("font-mono text-xs font-semibold tabular-nums", statusTone(e.status))}>
{e.status}
</span>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{e.content_type}</TableCell>
<TableCell className="text-right text-xs tabular-nums">{fmtBytes(e.resp_len)}</TableCell>
</TableRow>
))}
{exchanges.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="py-12 text-center text-sm text-muted-foreground">
{traffic === null ? t("table.loading") : t("table.empty")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</Card>
</div>
{linking ? (
<LinkTrafficDialog
trafficIds={[...selectedFlows]}
onClose={() => setLinking(false)}
onBound={() => setSelectedFlows(new Set())}
/>
) : null}
<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
{selected && (
<>
<SheetHeader className="border-b px-5 py-4">
<div className="flex items-center gap-2 pr-8">
<MethodBadge method={selected.method} />
<Badge variant="secondary" className={cn("font-mono tabular-nums", statusTone(selected.status))}>
{selected.status}
</Badge>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">{fmtTime(selected.ts)}</span>
</div>
<SheetTitle className="break-all font-mono">{selected.host}</SheetTitle>
<SheetDescription className="break-all font-mono">{selected.url}</SheetDescription>
</SheetHeader>
<Tabs defaultValue="request" className="min-h-0 flex-1 gap-0">
<TabsList className="mx-5 mt-4 grid w-auto grid-cols-2">
<TabsTrigger value="request">{t("detail.request")}</TabsTrigger>
<TabsTrigger value="response">{t("detail.response")}</TabsTrigger>
</TabsList>
<TabsContent value="request" className="min-h-0 overflow-auto">
{detailLoading ? (
<div className="flex items-center gap-2 p-5 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
{t("detail.loadingMsg")}
</div>
) : (
<HttpCodeBlock raw={requestWithHost(detail?.req ?? "", selected)} />
)}
</TabsContent>
<TabsContent value="response" className="min-h-0 overflow-auto">
{detailLoading ? (
<div className="flex items-center gap-2 p-5 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
{t("detail.loadingMsg")}
</div>
) : (
<HttpCodeBlock raw={detail?.resp ?? ""} />
)}
</TabsContent>
</Tabs>
</>
)}
</SheetContent>
</Sheet>
<AlertDialog
open={deleteMode !== null}
onOpenChange={(o) => {
if (!o) setDeleteMode(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{deleteTitle}</AlertDialogTitle>
<AlertDialogDescription>
{deleteMode === "all" &&
t.rich("dialog.descPurge", {
n: () => <span className="font-semibold tabular-nums">{traffic?.count ?? 0}</span>,
br: () => <br />,
muted: (chunks) => <span className="text-muted-foreground">{chunks}</span>,
})}
{deleteMode === "selected" &&
t.rich("dialog.descSelected", {
n: () => <span className="font-semibold tabular-nums">{selectedHosts.length}</span>,
hosts: () => (
<span className="font-mono">
{selectedHosts.slice(0, 3).join(", ")}
{selectedHosts.length > 3 ? "…" : ""}
</span>
),
})}
{deleteMode === "filter" &&
t.rich("dialog.descFilter", {
host: () => <span className="font-mono font-semibold">{hostQ}</span>,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>{t("dialog.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
confirmDelete();
}}
disabled={deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleting
? t(deleteMode === "all" ? "dialog.purging" : "dialog.deleting")
: t(deleteMode === "all" ? "dialog.confirmPurge" : "dialog.confirmDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,341 @@
"use client";
import * as React from "react";
import {
DownloadIcon,
FileIcon,
FolderIcon,
FolderPlusIcon,
HardDriveIcon,
RefreshCwIcon,
SaveIcon,
Trash2Icon,
UploadIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import type { WorkspaceEntry, WorkspaceFile } from "@/lib/types";
import { cn } from "@/lib/utils";
function fmtSize(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`;
}
function fmtTime(ms: number): string {
return new Date(ms).toLocaleString("ko-KR", {
year: "2-digit",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
type EditState = {
file: WorkspaceFile;
content: string;
dirty: boolean;
saving: boolean;
};
export default function WorkspacePage() {
const t = useTranslations("workspace");
const [path, setPath] = React.useState("");
const [entries, setEntries] = React.useState<WorkspaceEntry[]>([]);
const [loading, setLoading] = React.useState(true);
const [edit, setEdit] = React.useState<EditState | null>(null);
const [mkdirOpen, setMkdirOpen] = React.useState(false);
const [mkdirName, setMkdirName] = React.useState("");
const uploadRef = React.useRef<HTMLInputElement>(null);
const load = React.useCallback(
(p: string) => {
setLoading(true);
api
.workspaceList(p)
.then((r) => {
setEntries(r.entries);
setPath(r.path);
})
.catch((e) => toast.error(t("readDirFailed", { msg: (e as Error).message })))
.finally(() => setLoading(false));
},
[t],
);
React.useEffect(() => {
load("");
}, [load]);
const crumbs = React.useMemo(() => {
const parts = path ? path.split("/") : [];
const acc: { name: string; path: string }[] = [{ name: t("crumbRoot"), path: "" }];
let cur = "";
for (const part of parts) {
cur = cur ? `${cur}/${part}` : part;
acc.push({ name: part, path: cur });
}
return acc;
}, [path, t]);
const openFile = (e: WorkspaceEntry) => {
api
.workspaceRead(e.path)
.then((f) => setEdit({ file: f, content: f.content ?? "", dirty: false, saving: false }))
.catch((err) => toast.error(t("openFileFailed", { msg: (err as Error).message })));
};
const saveFile = () => {
if (!edit) return;
setEdit({ ...edit, saving: true });
api
.workspaceWrite(edit.file.path, edit.content)
.then(() => {
toast.success(t("saved"));
setEdit((cur) => (cur ? { ...cur, dirty: false, saving: false } : cur));
load(path);
})
.catch((err) => {
toast.error(t("saveFailed", { msg: (err as Error).message }));
setEdit((cur) => (cur ? { ...cur, saving: false } : cur));
});
};
const del = (e: WorkspaceEntry) => {
if (!window.confirm(t("confirmDelete", { kind: e.dir ? "dir" : "file", name: e.name }))) return;
api
.workspaceDelete(e.path)
.then(() => {
toast.success(t("deleted"));
load(path);
})
.catch((err) => toast.error(t("deleteFailed", { msg: (err as Error).message })));
};
const doUpload = (files: FileList | null) => {
if (!files || files.length === 0) return;
api
.workspaceUpload(path, Array.from(files))
.then((r) => {
toast.success(t("uploaded", { n: r.uploaded }));
load(path);
})
.catch((err) => toast.error(t("uploadFailed", { msg: (err as Error).message })))
.finally(() => {
if (uploadRef.current) uploadRef.current.value = "";
});
};
const doMkdir = () => {
const name = mkdirName.trim();
if (!name) return;
const target = path ? `${path}/${name}` : name;
api
.workspaceMkdir(target)
.then(() => {
toast.success(t("folderCreated"));
setMkdirOpen(false);
setMkdirName("");
load(path);
})
.catch((err) => toast.error(t("createFailed", { msg: (err as Error).message })));
};
return (
<div className="flex flex-col gap-4">
{/* 헤더: 경로(브레드크럼) + 동작 */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-1 text-sm">
<HardDriveIcon className="text-muted-foreground mr-1 size-4 shrink-0" />
{crumbs.map((c, i) => (
<React.Fragment key={c.path}>
{i > 0 && <span className="text-muted-foreground">/</span>}
<button
type="button"
onClick={() => load(c.path)}
className={cn(
"max-w-[160px] truncate rounded px-1.5 py-0.5 hover:bg-accent",
i === crumbs.length - 1 ? "text-foreground font-medium" : "text-muted-foreground",
)}
>
{c.name}
</button>
</React.Fragment>
))}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setMkdirOpen(true)}>
<FolderPlusIcon /> {t("newFolder")}
</Button>
<Button variant="outline" size="sm" onClick={() => uploadRef.current?.click()}>
<UploadIcon /> {t("upload")}
</Button>
<Button variant="ghost" size="icon" className="size-8" onClick={() => load(path)} title={t("refresh")}>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
</Button>
<input ref={uploadRef} type="file" multiple className="hidden" onChange={(e) => doUpload(e.target.files)} />
</div>
</div>
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("colName")}</TableHead>
<TableHead className="w-28 text-right">{t("colSize")}</TableHead>
<TableHead className="w-40">{t("colMtime")}</TableHead>
<TableHead className="w-24 text-right">{t("colActions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground py-10 text-center text-sm">
{loading ? t("loading") : t("empty")}
</TableCell>
</TableRow>
)}
{entries.map((e) => (
<TableRow key={e.path}>
<TableCell>
<button
type="button"
onClick={() => (e.dir ? load(e.path) : openFile(e))}
className="flex items-center gap-2 text-left hover:underline"
>
{e.dir ? (
<FolderIcon className="size-4 shrink-0 text-blue-500" />
) : (
<FileIcon className="text-muted-foreground size-4 shrink-0" />
)}
<span className="truncate font-mono text-sm">{e.name}</span>
</button>
</TableCell>
<TableCell className="text-muted-foreground text-right font-mono text-xs">
{e.dir ? "—" : fmtSize(e.size)}
</TableCell>
<TableCell className="text-muted-foreground text-xs">{fmtTime(e.mtime)}</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1">
{!e.dir && (
<Button
variant="ghost"
size="icon"
className="size-7"
title={t("download")}
onClick={() =>
api
.workspaceDownload(e.path)
.catch((err) => toast.error(t("downloadFailed", { msg: (err as Error).message })))
}
>
<DownloadIcon className="size-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="text-destructive size-7"
title={t("delete")}
onClick={() => del(e)}
>
<Trash2Icon className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 파일 보기 / 편집 */}
<Sheet open={edit !== null} onOpenChange={(o) => !o && setEdit(null)}>
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-2xl">
{edit && (
<>
<SheetHeader className="border-b p-4">
<SheetTitle className="flex items-center gap-2 truncate font-mono text-sm">
<FileIcon className="size-4 shrink-0" />
<span className="truncate" title={edit.file.path}>
{edit.file.path}
</span>
</SheetTitle>
<span className="text-muted-foreground text-xs">{fmtSize(edit.file.size)}</span>
</SheetHeader>
{edit.file.binary || edit.file.too_large ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
<p className="text-muted-foreground text-sm">{edit.file.too_large ? t("tooLarge") : t("binary")}</p>
<Button variant="outline" onClick={() => api.workspaceDownload(edit.file.path)}>
<DownloadIcon /> {t("downloadFile")}
</Button>
</div>
) : (
<>
<div className="min-h-0 flex-1 p-3">
<Textarea
value={edit.content}
onChange={(ev) => setEdit({ ...edit, content: ev.target.value, dirty: true })}
spellCheck={false}
className="h-full min-h-[50vh] resize-none font-mono text-xs leading-relaxed"
/>
</div>
<SheetFooter className="flex-row items-center justify-between border-t p-3">
<span className="text-muted-foreground text-xs">{edit.dirty ? t("dirty") : t("synced")}</span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => api.workspaceDownload(edit.file.path)}>
<DownloadIcon /> {t("download")}
</Button>
<Button onClick={saveFile} disabled={!edit.dirty || edit.saving}>
<SaveIcon /> {edit.saving ? t("saving") : t("save")}
</Button>
</div>
</SheetFooter>
</>
)}
</>
)}
</SheetContent>
</Sheet>
{/* 새 폴더 */}
<Dialog open={mkdirOpen} onOpenChange={setMkdirOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{t("newFolder")}</DialogTitle>
</DialogHeader>
<Input
autoFocus
value={mkdirName}
onChange={(e) => setMkdirName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && doMkdir()}
placeholder={t("folderNamePlaceholder")}
/>
<DialogFooter>
<Button variant="outline" onClick={() => setMkdirOpen(false)}>
{t("cancel")}
</Button>
<Button onClick={doMkdir} disabled={!mkdirName.trim()}>
{t("create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import type { ReactNode } from "react";
import * as React from "react";
import { AppSidebar } from "@/app/(main)/_components/sidebar/app-sidebar";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { auth } from "@/lib/auth";
import { getClientCookie } from "@/lib/cookie.client";
import {
SIDEBAR_COLLAPSIBLE_VALUES,
SIDEBAR_VARIANT_VALUES,
type SidebarCollapsible,
type SidebarVariant,
} from "@/lib/preferences/layout";
import { PREFERENCE_DEFAULTS } from "@/lib/preferences/preferences-config";
import { cn } from "@/lib/utils";
import { MainContent } from "./_components/main-content";
// Reads a layout-critical preference from the browser cookie (falls back to the
// default during static-export prerender where document is unavailable). Kept
// fully client-side so the app can be statically exported — no Server Actions /
// next/headers.
function readPref<T extends string>(key: string, allowed: readonly T[], fallback: T): T {
if (typeof document === "undefined") return fallback;
const value = getClientCookie(key);
return value && (allowed as readonly string[]).includes(value) ? (value as T) : fallback;
}
export default function Layout({ children }: Readonly<{ children: ReactNode }>) {
// Client-side auth gate — replaces the Next proxy/middleware that static export
// disables. No token → bounce to /login; render nothing until confirmed so no
// protected UI (or its API calls) flashes for a logged-out visitor.
const [authed, setAuthed] = React.useState(false);
React.useEffect(() => {
if (auth.getToken()) {
setAuthed(true);
} else {
// 客户端守卫认为未登录时,必须同时清掉 cookie:否则 proxy.ts 仅凭
// “cookie 存在”就把我们从 /login 又重定向回主界面,与本守卫来回弹跳
// 形成无限重定向 → 白屏(cookie 与 localStorage 不一致时触发)。
auth.clearToken();
window.location.href = "/login";
}
}, []);
const defaultOpen = typeof document === "undefined" ? true : getClientCookie("sidebar_state") !== "false";
const variant = readPref<SidebarVariant>(
"sidebar_variant",
SIDEBAR_VARIANT_VALUES,
PREFERENCE_DEFAULTS.sidebar_variant,
);
const collapsible = readPref<SidebarCollapsible>(
"sidebar_collapsible",
SIDEBAR_COLLAPSIBLE_VALUES,
PREFERENCE_DEFAULTS.sidebar_collapsible,
);
if (!authed) return null;
return (
<SidebarProvider
defaultOpen={defaultOpen}
style={
{
"--sidebar-width": "calc(var(--spacing) * 68)",
} as React.CSSProperties
}
>
<AppSidebar variant={variant} collapsible={collapsible} />
<SidebarInset
className={cn(
"[html[data-content-layout=centered]_&>*]:mx-auto",
"[html[data-content-layout=centered]_&>*]:w-full",
"[html[data-content-layout=centered]_&>*]:max-w-screen-2xl",
"peer-data-[variant=inset]:border",
"[--dashboard-header-height:--spacing(12)]",
"min-w-0 overflow-x-hidden",
)}
>
<MainContent>{children}</MainContent>
</SidebarInset>
</SidebarProvider>
);
}
@@ -0,0 +1,47 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { ArrowLeftIcon } from "lucide-react";
import { AgentEditor } from "@/components/agent-editor";
import { Button } from "@/components/ui/button";
// Deep-link editor page for one agent. The primary flow is the drawer on
// /system/agents; this page reuses the same AgentEditor component full-width so a
// direct URL (or an external link) still opens the editor.
function AgentDetailInner() {
const searchParams = useSearchParams();
const key = searchParams.get("key") ?? "";
return (
<div className="flex flex-1 flex-col gap-4">
<div className="flex items-center gap-2">
<Button asChild variant="ghost" size="icon-sm">
<Link href="/system/agents">
<ArrowLeftIcon className="size-4" />
</Link>
</Button>
<div>
<h1 className="text-xl font-semibold tracking-tight">Agent</h1>
<p className="text-muted-foreground font-mono text-xs">{key}</p>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col rounded-lg border">
<AgentEditor agentKey={key} />
</div>
</div>
);
}
// useSearchParams must sit under a Suspense boundary for static export.
export default function AgentDetailPage() {
return (
<React.Suspense fallback={null}>
<AgentDetailInner />
</React.Suspense>
);
}
+270
View File
@@ -0,0 +1,270 @@
"use client";
import * as React from "react";
import { Bot, PlusIcon, Trash2Icon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { AgentEditor } from "@/components/agent-editor";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import type { Agent } from "@/lib/types";
// AgentGridCard is one clickable tile opening the agent's editor drawer. Custom
// (non-builtin) agents get a delete button.
function AgentGridCard({ agent, onOpen, onDeleted }: { agent: Agent; onOpen: () => void; onDeleted: () => void }) {
const t = useTranslations("agentsPage");
async function del() {
try {
await api.deleteAgent(agent.key);
toast.success(t("deleted", { name: agent.name }));
onDeleted();
} catch (e) {
toast.error(t("deleteFailed", { msg: (e as Error).message }));
}
}
return (
<div className="hover:border-primary/50 group relative flex flex-col gap-2 rounded-lg border p-4 transition-colors">
<button type="button" onClick={onOpen} className="flex flex-col gap-2 text-left">
<div className="flex flex-wrap items-center gap-2">
<Bot className="text-muted-foreground size-4" />
<span className="text-sm font-medium">{agent.name}</span>
<span className="text-muted-foreground font-mono text-xs">{agent.key}</span>
{agent.builtin ? (
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
{t("badgeBuiltin")}
</Badge>
) : (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
{t("badgeCustom")}
</Badge>
)}
{!agent.enabled && (
<Badge variant="outline" className="text-destructive px-1.5 py-0 text-[10px]">
{t("badgeDisabled")}
</Badge>
)}
</div>
{/* biome-ignore lint/nursery/useNullishCoalescing: 설명 폴백 — 빈 설명도 안내 문구로 대체해야 하므로 || 가 의도된 동작이다(?? 는 빈 문자열을 그대로 노출한다) */}
<p className="text-muted-foreground line-clamp-2 min-h-8 text-xs">{agent.description || t("noDescription")}</p>
<div className="text-muted-foreground flex flex-wrap gap-1.5 text-[10px]">
<span className="rounded border px-1.5 py-0.5">MCP {agent.mcp_count ?? 0}</span>
<span className="rounded border px-1.5 py-0.5">Skill {agent.skill_count ?? 0}</span>
<span className="rounded border px-1.5 py-0.5">
{t("toolLabel")} {agent.tool_count ?? 0}
</span>
</div>
</button>
{!agent.builtin && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-destructive absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100"
>
<Trash2Icon className="size-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteTitle", { name: agent.name })}</AlertDialogTitle>
<AlertDialogDescription>{t("deleteDesc")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={del}>{t("delete")}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
);
}
function CreateAgentDialog({ onCreated }: { onCreated: (key: string) => void }) {
const t = useTranslations("agentsPage");
const [open, setOpen] = React.useState(false);
const [key, setKey] = React.useState("");
const [name, setName] = React.useState("");
const [description, setDescription] = React.useState("");
const [busy, setBusy] = React.useState(false);
async function create() {
setBusy(true);
try {
const a = await api.createAgent(key.trim(), name.trim(), description.trim());
toast.success(t("created", { name: a.name }));
setOpen(false);
setKey("");
setName("");
setDescription("");
onCreated(a.key);
} catch (e) {
toast.error(t("createFailed", { msg: (e as Error).message }));
} finally {
setBusy(false);
}
}
const keyOk = /^[a-z][a-z0-9_]*$/.test(key.trim());
const canCreate = keyOk && name.trim().length > 0 && !busy;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm">
<PlusIcon /> {t("new")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("createTitle")}</DialogTitle>
<DialogDescription>{t("createDesc")}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-1.5">
<Label htmlFor="agent-key">Key</Label>
<Input
id="agent-key"
placeholder={t("keyPlaceholder")}
value={key}
onChange={(e) => setKey(e.target.value)}
className="font-mono"
/>
{key.length > 0 && !keyOk && <span className="text-destructive text-xs">{t("keyError")}</span>}
</div>
<div className="grid gap-1.5">
<Label htmlFor="agent-name">{t("nameLabel")}</Label>
<Input
id="agent-name"
placeholder={t("namePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="agent-desc">{t("descLabel")}</Label>
<Textarea
id="agent-desc"
placeholder={t("descPlaceholder")}
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button onClick={create} disabled={!canCreate}>
{t("create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function AgentsPage() {
const t = useTranslations("agentsPage");
const [agents, setAgents] = React.useState<Agent[]>([]);
const [editKey, setEditKey] = React.useState<string | null>(null);
const reload = React.useCallback(() => {
api
.agents()
.then(setAgents)
.catch(() => setAgents([]));
}, []);
React.useEffect(() => {
reload();
}, [reload]);
const editing = agents.find((a) => a.key === editKey) ?? null;
return (
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div className="flex items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground text-sm">{t("subtitle")}</p>
</div>
<CreateAgentDialog
onCreated={(key) => {
reload();
setEditKey(key);
}}
/>
</div>
<Card>
<CardHeader>
<CardTitle>{t("listTitle")}</CardTitle>
<CardDescription>{t("countTotal", { count: agents.length })}</CardDescription>
</CardHeader>
<CardContent>
{agents.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">{t("empty")}</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{agents.map((a) => (
<AgentGridCard key={a.key} agent={a} onOpen={() => setEditKey(a.key)} onDeleted={reload} />
))}
</div>
)}
</CardContent>
</Card>
<Sheet open={!!editing} onOpenChange={(o) => !o && setEditKey(null)}>
<SheetContent
side="right"
className="flex flex-col gap-0 p-0 data-[side=right]:w-[45vw] data-[side=right]:sm:max-w-[45vw]"
>
{editing && (
<>
<SheetHeader className="px-4">
<SheetTitle className="flex items-center gap-2">
{editing.name}
<span className="text-muted-foreground font-mono text-xs">{editing.key}</span>
{!editing.builtin && (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
{t("badgeCustom")}
</Badge>
)}
</SheetTitle>
<SheetDescription>{editing.description || t("editorDescFallback")}</SheetDescription>
</SheetHeader>
<AgentEditor agentKey={editing.key} onSaved={reload} />
</>
)}
</SheetContent>
</Sheet>
</div>
);
}
@@ -0,0 +1,9 @@
import { ApprovalRecords } from "@/components/approval-records";
export default function ApprovalsPage() {
return (
<div className="flex min-w-0 flex-1 flex-col p-4 sm:p-6">
<ApprovalRecords />
</div>
);
}
@@ -0,0 +1,350 @@
"use client";
import * as React from "react";
import { BanIcon, PencilIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import type { AssetInterceptKind, AssetInterceptRule } from "@/lib/types";
// ---- kind 메타 정보 ----
// label 은 assetInterceptRules.kind.* 에서, group 헤더는 assetInterceptPage.group.* 에서 해석한다.
type KindGroup = "exact" | "fuzzy" | "cidr";
const KIND_OPTIONS: { value: AssetInterceptKind; group: KindGroup; placeholder: string }[] = [
{ value: "exact_domain", group: "exact", placeholder: "example.gov.cn" },
{ value: "exact_ip", group: "exact", placeholder: "203.0.113.10" },
{ value: "exact_url", group: "exact", placeholder: "https://example.gov.cn/login" },
{ value: "fuzzy_domain", group: "fuzzy", placeholder: ".gov.cn" },
{ value: "fuzzy_ip", group: "fuzzy", placeholder: "203.0.113." },
{ value: "fuzzy_url", group: "fuzzy", placeholder: "/admin" },
{ value: "cidr", group: "cidr", placeholder: "192.168.0.0/16" },
];
const KIND_GROUPS: KindGroup[] = ["exact", "fuzzy", "cidr"];
function KindBadge({ kind, label }: { kind: AssetInterceptKind; label: string }) {
const fuzzy = kind.startsWith("fuzzy_");
const cidr = kind === "cidr";
return (
<Badge
variant="outline"
className={
cidr
? "border-sky-400 text-sky-600"
: fuzzy
? "border-amber-400 text-amber-600"
: "border-emerald-400 text-emerald-600"
}
>
{label}
</Badge>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="space-y-1.5">
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{label}</Label>
{children}
{hint && <p className="text-[11px] text-muted-foreground">{hint}</p>}
</div>
);
}
// ---- form state ----
type RuleForm = {
enabled: boolean;
kind: AssetInterceptKind;
pattern: string;
note: string;
};
const defaultForm = (): RuleForm => ({ enabled: true, kind: "fuzzy_domain", pattern: "", note: "" });
// 프런트 가벼운 검증(백엔드와 일치: exact_ip / cidr 만 형식을 검증하고 나머지는 백엔드에 맡긴다).
// 오류 코드를 돌려주고, 화면에서 번역 메시지로 바꾼다.
function frontValidate(form: RuleForm): "contentRequired" | "cidrInvalid" | null {
const p = form.pattern.trim();
if (!p) return "contentRequired";
if (form.kind === "cidr" && !/^[0-9a-fA-F:.]+\/\d{1,3}$/.test(p)) {
return "cidrInvalid";
}
return null;
}
// ---- page ----
export default function AssetInterceptPage() {
const t = useTranslations("assetInterceptPage");
const tr = useTranslations("assetInterceptRules");
const [rules, setRules] = React.useState<AssetInterceptRule[]>([]);
const [loading, setLoading] = React.useState(true);
const [open, setOpen] = React.useState(false);
const [editing, setEditing] = React.useState<AssetInterceptRule | null>(null);
const [form, setForm] = React.useState<RuleForm>(defaultForm());
const [saving, setSaving] = React.useState(false);
const load = React.useCallback(async () => {
try {
setRules(await api.assetInterceptRules());
} catch {
toast.error(t("toast.loadFailed"));
} finally {
setLoading(false);
}
}, [t]);
React.useEffect(() => {
void load();
}, [load]);
function set(patch: Partial<RuleForm>) {
setForm((f) => ({ ...f, ...patch }));
}
function openNew() {
setEditing(null);
setForm(defaultForm());
setOpen(true);
}
function openEdit(rule: AssetInterceptRule) {
setEditing(rule);
setForm({ enabled: rule.enabled, kind: rule.kind, pattern: rule.pattern, note: rule.note });
setOpen(true);
}
async function handleSave() {
const err = frontValidate(form);
if (err) {
toast.error(t(`toast.${err}`));
return;
}
const payload = { ...form, pattern: form.pattern.trim() };
setSaving(true);
try {
if (editing) {
await api.updateAssetInterceptRule(editing.id, payload);
toast.success(t("toast.ruleUpdated"));
} else {
await api.createAssetInterceptRule(payload);
toast.success(t("toast.ruleCreated"));
}
setOpen(false);
void load();
} catch (e) {
toast.error((e as Error).message);
} finally {
setSaving(false);
}
}
async function handleDelete(rule: AssetInterceptRule) {
if (!window.confirm(t("deleteConfirm", { pattern: rule.pattern }))) return;
try {
await api.deleteAssetInterceptRule(rule.id);
toast.success(t("toast.ruleDeleted"));
void load();
} catch (e) {
toast.error((e as Error).message);
}
}
async function handleToggle(rule: AssetInterceptRule) {
try {
await api.toggleAssetInterceptRule(rule.id, !rule.enabled);
void load();
} catch (e) {
toast.error((e as Error).message);
}
}
const placeholder = KIND_OPTIONS.find((o) => o.value === form.kind)?.placeholder ?? "";
return (
<div className="flex flex-1 flex-col gap-5 p-6">
{/* header */}
<div className="flex items-center gap-2.5">
<BanIcon className="h-5 w-5 shrink-0" />
<div>
<h1 className="text-lg font-semibold leading-tight">{t("title")}</h1>
<p className="text-sm text-muted-foreground mt-0.5">{t("subtitle")}</p>
</div>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">{t("intro")}</p>
<Button onClick={openNew} size="sm" className="shrink-0">
<PlusIcon className="h-4 w-4" />
{t("add")}
</Button>
</div>
<Card>
<CardContent className="p-0">
{loading ? (
<p className="p-6 text-sm text-muted-foreground">{t("loading")}</p>
) : rules.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
<BanIcon className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">{t("empty")}</p>
<Button size="sm" variant="outline" onClick={openNew}>
<PlusIcon className="h-4 w-4" />
{t("addFirst")}
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[130px]">{t("colType")}</TableHead>
<TableHead>{t("colPattern")}</TableHead>
<TableHead>{t("colNote")}</TableHead>
<TableHead className="w-[64px] text-center">{t("colEnabled")}</TableHead>
<TableHead className="w-[80px]" />
</TableRow>
</TableHeader>
<TableBody>
{rules.map((rule) => (
<TableRow key={rule.id} className={!rule.enabled ? "opacity-40" : ""}>
<TableCell>
<KindBadge kind={rule.kind} label={tr(`kind.${rule.kind}`)} />
</TableCell>
<TableCell className="max-w-[280px]">
<code className="block truncate rounded bg-muted px-1.5 py-0.5 text-xs font-mono">
{rule.pattern}
</code>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
<div className="flex items-center gap-1.5">
{rule.builtin && (
<Badge variant="secondary" className="shrink-0 px-1 py-0 text-[10px]">
{t("builtin")}
</Badge>
)}
<span className="truncate">{rule.note}</span>
</div>
</TableCell>
<TableCell className="text-center">
<Switch checked={rule.enabled} onCheckedChange={() => handleToggle(rule)} />
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-0.5">
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => openEdit(rule)}>
<PencilIcon className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(rule)}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* editor sheet */}
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="right" className="flex flex-col gap-0 p-0 sm:max-w-md">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{editing ? t("editor.editTitle") : t("editor.newTitle")}</SheetTitle>
<SheetDescription className="text-xs">{t("editor.desc")}</SheetDescription>
</SheetHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 space-y-5">
<Field label={t("editor.matchType")}>
<Select value={form.kind} onValueChange={(v) => set({ kind: v as AssetInterceptKind })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{KIND_GROUPS.map((g) => (
<React.Fragment key={g}>
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{t(`group.${g}`)}
</div>
{KIND_OPTIONS.filter((o) => o.group === g).map((o) => (
<SelectItem key={o.value} value={o.value}>
{tr(`kind.${o.value}`)}
</SelectItem>
))}
</React.Fragment>
))}
</SelectContent>
</Select>
</Field>
<Field
label={t("editor.matchContent")}
hint={
form.kind === "cidr"
? t("editor.hintCidr")
: form.kind.startsWith("fuzzy_")
? t("editor.hintFuzzy")
: t("editor.hintExact")
}
>
<Input
placeholder={placeholder}
value={form.pattern}
onChange={(e) => set({ pattern: e.target.value })}
/>
</Field>
<Field label={t("editor.note")}>
<Textarea
placeholder={t("editor.notePlaceholder")}
value={form.note}
onChange={(e) => set({ note: e.target.value })}
rows={2}
className="resize-none"
/>
</Field>
<Separator />
<div className="flex items-center gap-3">
<Switch id="asset-rule-enabled" checked={form.enabled} onCheckedChange={(v) => set({ enabled: v })} />
<Label htmlFor="asset-rule-enabled" className="cursor-pointer">
{t("editor.enableRule")}
</Label>
</div>
</div>
<SheetFooter className="border-t px-6 py-4 flex-row justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)}>
{t("editor.cancel")}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? t("editor.saving") : t("editor.save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
);
}
@@ -0,0 +1,902 @@
"use client";
import * as React from "react";
import { BotIcon, ListFilterIcon, PencilIcon, PlusIcon, ShieldAlertIcon, Trash2Icon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import type { InterceptAction, InterceptRule, JudgeConfig, LLMProfile, Tool } from "@/lib/types";
// ---- tool scope ----
// SDK tools are intentionally not seeded into the DB (they apply to every agent
// and have no per-agent binding). We hardcode their keys here so they still
// appear in the scope dialog; descriptions are resolved from i18n at render time.
const SDK_EXEC_KEYS = [
"Bash",
"WebFetch",
"web_search",
"shell_open",
"shell_send",
"shell_read",
"shell_close",
"shell_list",
] as const;
const SDK_WRITE_KEYS = ["Write", "Edit", "MultiEdit"] as const;
const SDK_KEYS = new Set<string>([...SDK_EXEC_KEYS, ...SDK_WRITE_KEYS]);
// ---- form state ----
type RuleForm = {
name: string;
enabled: boolean;
priority: number;
match_target: "tool_name" | "tool_input";
match_type: "string" | "regex";
pattern: string;
action: InterceptAction;
message: string;
timeout_enabled: boolean;
timeout_seconds: number;
timeout_action: "deny" | "allow";
};
const defaultForm = (): RuleForm => ({
name: "",
enabled: true,
priority: 0,
match_target: "tool_name",
match_type: "string",
pattern: "",
action: "deny",
message: "",
timeout_enabled: true,
timeout_seconds: 60,
timeout_action: "deny",
});
// ---- small components ----
function ActionBadge({ action, label }: { action: InterceptAction; label: string }) {
if (action === "allow") return <Badge variant="secondary">{label}</Badge>;
if (action === "deny") return <Badge variant="destructive">{label}</Badge>;
return (
<Badge variant="outline" className="border-amber-400 text-amber-600">
{label}
</Badge>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="space-y-1.5">
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{label}</Label>
{children}
</div>
);
}
// ---- LLM fallback judge card ----
const FOLLOW_ACTIVE = "0"; // profile_id 0 = 활성/기본 설정 따르기
const defaultJudge = (): JudgeConfig => ({
enabled: false,
profile_id: 0,
prompt: "",
timeout_seconds: 15,
fail_action: "allow",
ask_timeout_seconds: 300,
ask_timeout_action: "deny",
});
function JudgeCard() {
const t = useTranslations("interceptPage");
const [cfg, setCfg] = React.useState<JudgeConfig>(defaultJudge());
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const load = React.useCallback(async () => {
setLoading(true);
try {
const [j, ps] = await Promise.all([api.interceptGetJudgeConfig(), api.llmProfiles()]);
setCfg(j);
setProfiles(ps);
} catch (e) {
toast.error(t("judge.loadConfigFailed", { error: (e as Error).message }));
} finally {
setLoading(false);
}
}, [t]);
React.useEffect(() => {
void load();
}, [load]);
function patch(p: Partial<JudgeConfig>) {
setCfg((c) => ({ ...c, ...p }));
}
async function save() {
setSaving(true);
try {
await api.interceptSetJudgeConfig(cfg);
toast.success(t("judge.configSaved"));
await load(); // 되읽기: 프롬프트를 비우면 내장 템플릿을 다시 채운다
} catch (e) {
toast.error(t("toast.saveFailed", { error: (e as Error).message }));
} finally {
setSaving(false);
}
}
async function restorePrompt() {
// 프롬프트를 비우고 저장하면 서버가 다음 응답에 내장 템플릿 전문을 돌려주고, 입력창에 다시 채워진다.
setSaving(true);
try {
await api.interceptSetJudgeConfig({ ...cfg, prompt: "" });
const j = await api.interceptGetJudgeConfig();
setCfg(j);
toast.success(t("judge.restored"));
} catch (e) {
toast.error(t("judge.restoreFailed", { error: (e as Error).message }));
} finally {
setSaving(false);
}
}
return (
<div className="space-y-4">
{/* 사용 스위치 — 독립 강조 바 */}
<div
className={`flex items-center justify-between gap-3 rounded-lg border px-4 py-3 ${
cfg.enabled ? "border-violet-400/50 bg-violet-50/40 dark:bg-violet-950/20" : "bg-muted/40"
}`}
>
<div className="flex items-center gap-2.5">
<BotIcon className={`h-5 w-5 shrink-0 ${cfg.enabled ? "text-violet-600" : "text-muted-foreground"}`} />
<div>
<p className="text-sm font-semibold leading-tight">{t("judge.title")}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{t.rich("judge.desc", {
scope: (c) => <span className="font-medium text-foreground">{c}</span>,
norule: (c) => <span className="font-medium text-foreground">{c}</span>,
})}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="text-xs text-muted-foreground">
{cfg.enabled ? t("judge.enabled") : t("judge.disabled")}
</span>
<Switch checked={cfg.enabled} disabled={loading} onCheckedChange={(v) => patch({ enabled: v })} />
</div>
</div>
{cfg.enabled && (
<div className="grid gap-4 lg:grid-cols-5">
{/* 왼쪽: 프롬프트 편집기(바로 펼침, 주 영역) */}
<Card className="lg:col-span-3">
<CardContent className="flex h-full flex-col gap-2 p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{t("judge.promptTitle")}</p>
<p className="text-xs text-muted-foreground">{t("judge.promptDesc")}</p>
</div>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={restorePrompt} disabled={saving}>
{t("judge.restoreTemplate")}
</Button>
</div>
<Textarea
className="min-h-[22rem] flex-1 resize-none font-mono text-xs leading-relaxed"
value={cfg.prompt}
onChange={(e) => patch({ prompt: e.target.value })}
placeholder={t("judge.promptPlaceholder")}
spellCheck={false}
/>
<p className="text-right text-[11px] text-muted-foreground">
{t("judge.charCount", { count: cfg.prompt.length })}
</p>
</CardContent>
</Card>
{/* 오른쪽: 판정 매개변수(설정 칸) */}
<Card className="lg:col-span-2">
<CardContent className="space-y-5 p-4">
<div className="space-y-4">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{t("judge.modelAndPolicy")}
</p>
<Field label={t("judge.approvalModel")}>
<Select value={String(cfg.profile_id || 0)} onValueChange={(v) => patch({ profile_id: Number(v) })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={FOLLOW_ACTIVE}>{t("judge.followActive")}</SelectItem>
{profiles.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}({p.model})
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label={t("judge.judgeTimeout")}>
<Input
type="number"
min={1}
value={cfg.timeout_seconds}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
if (n > 0) patch({ timeout_seconds: n });
}}
/>
</Field>
<Field label={t("judge.failAction")}>
<Select
value={cfg.fail_action}
onValueChange={(v) => patch({ fail_action: v as JudgeConfig["fail_action"] })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="allow">{t("judge.failAllow")}</SelectItem>
<SelectItem value="ask">{t("judge.failAsk")}</SelectItem>
<SelectItem value="deny">{t("judge.failDeny")}</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
<Separator />
<div className="space-y-4">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{t("judge.manualApproval")}
</p>
<Field label={t("judge.askTimeout")}>
<Input
type="number"
min={5}
value={cfg.ask_timeout_seconds}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
if (n > 0) patch({ ask_timeout_seconds: n });
}}
/>
</Field>
<Field label={t("judge.askTimeoutAction")}>
<Select
value={cfg.ask_timeout_action}
onValueChange={(v) => patch({ ask_timeout_action: v as JudgeConfig["ask_timeout_action"] })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="deny">{t("judge.askTimeoutDeny")}</SelectItem>
<SelectItem value="allow">{t("judge.askTimeoutAllow")}</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
</CardContent>
</Card>
</div>
)}
<div className="flex justify-end">
<Button size="sm" onClick={save} disabled={saving || loading}>
{saving ? t("judge.saving") : t("judge.saveConfig")}
</Button>
</div>
</div>
);
}
// ---- page ----
export default function InterceptPage() {
const t = useTranslations("interceptPage");
const [rules, setRules] = React.useState<InterceptRule[]>([]);
const [loading, setLoading] = React.useState(true);
const [open, setOpen] = React.useState(false);
const [editing, setEditing] = React.useState<InterceptRule | null>(null);
const [form, setForm] = React.useState<RuleForm>(defaultForm());
const [saving, setSaving] = React.useState(false);
const [regexErr, setRegexErr] = React.useState("");
const [regexWarn, setRegexWarn] = React.useState(false); // true = JS 는 못 파싱하지만 Go 에서는 유효한 문법일 수 있음
// ---- tool scope dialog ----
const [scopeOpen, setScopeOpen] = React.useState(false);
const [allTools, setAllTools] = React.useState<Tool[]>([]);
const [enabledTools, setEnabledTools] = React.useState<Set<string>>(new Set());
const [scopeLoading, setScopeLoading] = React.useState(false);
const [scopeSaving, setScopeSaving] = React.useState(false);
const [scopeTools, setScopeTools] = React.useState<string[]>([]); // 페이지 머리 정보 바: 현재 가로채기에 들어간 도구
// ---- data ----
const loadScope = React.useCallback(async () => {
try {
const cfg = await api.interceptGetToolConfig();
setScopeTools(cfg.enabled_tools);
} catch {
// 정보 바는 핵심이 아니라 실패하면 조용히 넘긴다
}
}, []);
const load = React.useCallback(async () => {
try {
const r = await api.interceptRules();
setRules(r);
} catch {
toast.error(t("toast.loadRulesFailed"));
} finally {
setLoading(false);
}
}, [t]);
React.useEffect(() => {
void load();
void loadScope();
}, [load, loadScope]);
React.useEffect(() => {
if (form.match_type !== "regex" || !form.pattern) {
setRegexErr("");
setRegexWarn(false);
return;
}
try {
new RegExp(form.pattern);
setRegexErr("");
setRegexWarn(false);
} catch {
// JS RegExp 는 Go RE2 확장 문법(예: (?i) 인라인 플래그)을 지원하지 않는다.
// 여기서의 미리 보기 검증 실패가 Go 에서 무효하다는 뜻은 아니며, 최종 검증은 서버에 맡긴다.
setRegexErr("");
setRegexWarn(true);
}
}, [form.pattern, form.match_type]);
// ---- rule handlers ----
function set(patch: Partial<RuleForm>) {
setForm((f) => ({ ...f, ...patch }));
}
function openNew() {
setEditing(null);
setForm(defaultForm());
setRegexErr("");
setOpen(true);
}
function openEdit(rule: InterceptRule) {
setEditing(rule);
setForm({
name: rule.name,
enabled: rule.enabled,
priority: rule.priority,
match_target: rule.match_target,
match_type: rule.match_type,
pattern: rule.pattern,
action: rule.action,
message: rule.message,
timeout_enabled: rule.timeout_enabled,
timeout_seconds: rule.timeout_seconds,
timeout_action: rule.timeout_action,
});
setRegexErr("");
setOpen(true);
}
async function handleSave() {
if (!form.name.trim()) {
toast.error(t("toast.nameRequired"));
return;
}
if (!form.pattern.trim()) {
toast.error(t("toast.patternRequired"));
return;
}
if (regexErr) {
toast.error(t("toast.regexInvalid"));
return;
}
setSaving(true);
try {
if (editing) {
await api.updateInterceptRule(editing.id, form);
toast.success(t("toast.ruleUpdated"));
} else {
await api.createInterceptRule(form);
toast.success(t("toast.ruleCreated"));
}
setOpen(false);
void load();
} catch (e) {
toast.error((e as Error).message);
} finally {
setSaving(false);
}
}
async function handleDelete(id: number) {
try {
await api.deleteInterceptRule(id);
toast.success(t("toast.ruleDeleted"));
void load();
} catch (e) {
toast.error((e as Error).message);
}
}
async function handleToggle(rule: InterceptRule) {
try {
await api.toggleInterceptRule(rule.id, !rule.enabled);
void load();
} catch (e) {
toast.error((e as Error).message);
}
}
// ---- scope handlers ----
async function openScope() {
setScopeOpen(true);
setScopeLoading(true);
try {
const [tools, cfg] = await Promise.all([api.tools(), api.interceptGetToolConfig()]);
setAllTools(tools);
setEnabledTools(new Set(cfg.enabled_tools));
} catch (e) {
toast.error(t("toast.loadFailed", { error: (e as Error).message }));
} finally {
setScopeLoading(false);
}
}
function toggleTool(key: string, val: boolean) {
setEnabledTools((prev) => {
const next = new Set(prev);
if (val) next.add(key);
else next.delete(key);
return next;
});
}
async function saveScope() {
setScopeSaving(true);
try {
await api.interceptSetToolConfig([...enabledTools]);
toast.success(t("toast.scopeSaved"));
setScopeTools([...enabledTools]);
setScopeOpen(false);
} catch (e) {
toast.error(t("toast.saveFailed", { error: (e as Error).message }));
} finally {
setScopeSaving(false);
}
}
const toolGroups = React.useMemo(() => {
const mk = (key: string): Tool => ({
key,
system: true,
description: t(`toolDesc.${key}`),
schema: {},
agents: [],
enabled: true,
kind: "builtin",
});
const sdkExec = SDK_EXEC_KEYS.map(mk);
const sdkWrite = SDK_WRITE_KEYS.map(mk);
const sys: Tool[] = [],
custom: Tool[] = [];
for (const tool of allTools) {
if (SDK_KEYS.has(tool.key)) continue; // 하드코딩 그룹에서 이미 다룸
if (tool.system) sys.push(tool);
else custom.push(tool);
}
return [
{ label: t("toolGroup.exec"), tools: sdkExec },
{ label: t("toolGroup.write"), tools: sdkWrite },
{ label: t("toolGroup.system"), tools: sys },
{ label: t("toolGroup.custom"), tools: custom },
].filter((g) => g.tools.length > 0);
}, [allTools, t]);
// ---- render ----
return (
<div className="flex flex-1 flex-col gap-5 p-6">
{/* ---- header ---- */}
<div className="flex items-center gap-2.5">
<ShieldAlertIcon className="h-5 w-5 shrink-0" />
<div>
<h1 className="text-lg font-semibold leading-tight">{t("title")}</h1>
<p className="text-sm text-muted-foreground mt-0.5">{t("subtitle")}</p>
</div>
</div>
{/* ---- 가로채기 범위 정보 바(규칙 대조와 모델 폴백 공용: 범위 밖 도구는 둘 다 개입 안 함) ---- */}
<div
className={`flex items-center justify-between gap-3 rounded-lg border px-4 py-2.5 ${
scopeTools.length === 0 ? "border-amber-400/60 bg-amber-50/50 dark:bg-amber-950/20" : "bg-muted/40"
}`}
>
<div className="flex min-w-0 items-center gap-2 text-sm">
<ListFilterIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="shrink-0 font-medium">{t("scope.label")}</span>
{scopeTools.length === 0 ? (
<span className="text-amber-700 dark:text-amber-500">{t("scope.empty")}</span>
) : (
<>
<Badge variant="secondary" className="shrink-0">
{t("scope.toolCount", { count: scopeTools.length })}
</Badge>
<span className="truncate text-muted-foreground" title={scopeTools.join(" · ")}>
{scopeTools.join(" · ")}
</span>
</>
)}
</div>
<Button
variant={scopeTools.length === 0 ? "default" : "outline"}
size="sm"
className="shrink-0"
onClick={openScope}
>
<ListFilterIcon className="h-4 w-4" />
{t("scope.adjust")}
</Button>
</div>
<Tabs defaultValue="rules" className="flex-1">
<TabsList>
<TabsTrigger value="rules">{t("tab.rules")}</TabsTrigger>
<TabsTrigger value="judge">{t("tab.model")}</TabsTrigger>
</TabsList>
{/* ---- tab: rules ---- */}
<TabsContent value="rules" className="mt-4 flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">{t("rules.priorityHint")}</p>
<Button onClick={openNew} size="sm" className="shrink-0">
<PlusIcon className="h-4 w-4" />
{t("rules.add")}
</Button>
</div>
<Card>
<CardContent className="p-0">
{loading ? (
<p className="p-6 text-sm text-muted-foreground">{t("rules.loading")}</p>
) : rules.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
<ShieldAlertIcon className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">{t("rules.empty")}</p>
<Button size="sm" variant="outline" onClick={openNew}>
<PlusIcon className="h-4 w-4" />
{t("rules.addFirst")}
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-[72px]">{t("rules.colPriority")}</TableHead>
<TableHead>{t("rules.colName")}</TableHead>
<TableHead className="w-[90px]">{t("rules.colTarget")}</TableHead>
<TableHead className="w-[80px]">{t("rules.colType")}</TableHead>
<TableHead>{t("rules.colPattern")}</TableHead>
<TableHead className="w-[72px]">{t("rules.colAction")}</TableHead>
<TableHead className="w-[64px] text-center">{t("rules.colEnabled")}</TableHead>
<TableHead className="w-[80px]" />
</TableRow>
</TableHeader>
<TableBody>
{rules.map((rule) => (
<TableRow key={rule.id} className={!rule.enabled ? "opacity-40" : ""}>
<TableCell>
<span className="font-mono text-xs tabular-nums">{rule.priority}</span>
</TableCell>
<TableCell className="font-medium text-sm">{rule.name}</TableCell>
<TableCell>
<span className="text-xs text-muted-foreground">
{rule.match_target === "tool_name" ? t("target.tool_name") : t("target.tool_input")}
</span>
</TableCell>
<TableCell>
<span className="text-xs text-muted-foreground">
{rule.match_type === "regex" ? t("matchType.regex") : t("matchType.string")}
</span>
</TableCell>
<TableCell className="max-w-[220px]">
<code className="block truncate rounded bg-muted px-1.5 py-0.5 text-xs font-mono">
{rule.pattern}
</code>
</TableCell>
<TableCell>
<ActionBadge action={rule.action} label={t(`action.${rule.action}`)} />
</TableCell>
<TableCell className="text-center">
<Switch checked={rule.enabled} onCheckedChange={() => handleToggle(rule)} />
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-0.5">
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => openEdit(rule)}>
<PencilIcon className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(rule.id)}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
{/* ---- tab: model ---- */}
<TabsContent value="judge" className="mt-4">
<JudgeCard />
</TabsContent>
</Tabs>
{/* ---- editor sheet ---- */}
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="right" className="flex flex-col gap-0 p-0 sm:max-w-md">
<SheetHeader className="border-b px-6 py-4">
<SheetTitle>{editing ? t("editor.editTitle") : t("editor.newTitle")}</SheetTitle>
<SheetDescription className="text-xs">{t("editor.desc")}</SheetDescription>
</SheetHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 space-y-5">
<Field label={t("editor.name")}>
<Input
placeholder={t("editor.namePlaceholder")}
value={form.name}
onChange={(e) => set({ name: e.target.value })}
/>
</Field>
<Field label={t("editor.priority")}>
<Input
type="number"
value={form.priority}
onChange={(e) => set({ priority: parseInt(e.target.value) || 0 })}
/>
</Field>
<Separator />
<Field label={t("editor.matchTarget")}>
<Select
value={form.match_target}
onValueChange={(v) => set({ match_target: v as RuleForm["match_target"] })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tool_name">{t("editor.targetToolName")}</SelectItem>
<SelectItem value="tool_input">{t("editor.targetToolInput")}</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label={t("editor.matchTypeLabel")}>
<Select value={form.match_type} onValueChange={(v) => set({ match_type: v as RuleForm["match_type"] })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">{t("editor.typeStringContains")}</SelectItem>
<SelectItem value="regex">{t("editor.typeRegex")}</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label={t("editor.pattern")}>
<Input
placeholder={form.match_type === "regex" ? "^Bash$" : "rm -rf"}
value={form.pattern}
onChange={(e) => set({ pattern: e.target.value })}
className={regexErr ? "border-destructive focus-visible:ring-destructive" : ""}
/>
{regexErr && <p className="text-xs text-destructive mt-1">{regexErr}</p>}
{regexWarn && (
<p className="text-xs text-amber-600 mt-1">
{t.rich("editor.regexWarn", { code: (c) => <code className="font-mono">{c}</code> })}
</p>
)}
</Field>
<Separator />
<Field label={t("editor.actionLabel")}>
<Select value={form.action} onValueChange={(v) => set({ action: v as InterceptAction })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="allow">{t("editor.actionAllow")}</SelectItem>
<SelectItem value="deny">{t("editor.actionDeny")}</SelectItem>
<SelectItem value="ask">{t("editor.actionAsk")}</SelectItem>
</SelectContent>
</Select>
</Field>
{form.action !== "allow" && (
<Field label={form.action === "deny" ? t("editor.denyMessage") : t("editor.askMessage")}>
<Textarea
placeholder={form.action === "deny" ? t("editor.denyMessagePlaceholder") : ""}
value={form.message}
onChange={(e) => set({ message: e.target.value })}
rows={2}
className="resize-none"
/>
</Field>
)}
{form.action === "ask" && (
<>
<Separator />
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{t("editor.enableTimeout")}</p>
<p className="text-xs text-muted-foreground">{t("editor.enableTimeoutHint")}</p>
</div>
<Switch checked={form.timeout_enabled} onCheckedChange={(v) => set({ timeout_enabled: v })} />
</div>
{form.timeout_enabled && (
<div className="flex items-end gap-3">
<Field label={t("editor.timeoutSeconds")}>
<Input
type="number"
min={5}
className="w-28"
value={form.timeout_seconds}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
if (n > 0) set({ timeout_seconds: n });
}}
/>
</Field>
<Field label={t("editor.timeoutAction")}>
<Select
value={form.timeout_action}
onValueChange={(v) => set({ timeout_action: v as "deny" | "allow" })}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="deny">{t("editor.timeoutDeny")}</SelectItem>
<SelectItem value="allow">{t("editor.timeoutAllow")}</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
)}
</>
)}
<Separator />
<div className="flex items-center gap-3">
<Switch id="rule-enabled" checked={form.enabled} onCheckedChange={(v) => set({ enabled: v })} />
<Label htmlFor="rule-enabled" className="cursor-pointer">
{t("editor.enableRule")}
</Label>
</div>
</div>
<SheetFooter className="border-t px-6 py-4 flex-row justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)}>
{t("editor.cancel")}
</Button>
<Button onClick={handleSave} disabled={saving || !!regexErr}>
{saving ? t("editor.saving") : t("editor.save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
{/* ---- scope dialog ---- */}
<Dialog open={scopeOpen} onOpenChange={setScopeOpen}>
<DialogContent
className="sm:max-w-lg flex flex-col overflow-hidden p-0 gap-0"
style={{ maxHeight: "min(80vh, 560px)" }}
>
<DialogHeader className="shrink-0 border-b px-6 py-4">
<DialogTitle className="flex items-center gap-2">
<ListFilterIcon className="h-4 w-4" />
{t("scope.dialogTitle")}
</DialogTitle>
<DialogDescription className="text-xs">{t("scope.dialogDesc")}</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-4 space-y-5">
{scopeLoading ? (
<p className="text-sm text-muted-foreground py-4">{t("rules.loading")}</p>
) : (
toolGroups.map((group, gi) => (
<div key={group.label}>
{gi > 0 && <Separator className="mb-5" />}
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
{group.label}
</p>
<div className="space-y-0.5">
{group.tools.map((tool) => (
<div key={tool.key} className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50">
<Switch
id={`scope-${tool.key}`}
checked={enabledTools.has(tool.key)}
onCheckedChange={(v) => toggleTool(tool.key, v)}
/>
<label htmlFor={`scope-${tool.key}`} className="flex-1 min-w-0 cursor-pointer">
<div className="flex items-center gap-1.5">
<span className="font-mono text-sm">{tool.key}</span>
{tool.kind && tool.kind !== "builtin" && (
<Badge variant="outline" className="px-1 py-0 text-[10px]">
{tool.kind}
</Badge>
)}
</div>
{tool.description && (
<p className="text-[11px] text-muted-foreground line-clamp-1">{tool.description}</p>
)}
</label>
</div>
))}
</div>
</div>
))
)}
</div>
<div className="shrink-0 border-t px-6 py-3 flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => setScopeOpen(false)}>
{t("editor.cancel")}
</Button>
<Button size="sm" onClick={saveScope} disabled={scopeSaving || scopeLoading}>
{scopeSaving ? t("editor.saving") : t("editor.save")}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,274 @@
"use client";
// LLM 重试配置的共用件:五层重试各自的「次数 + 间隔」。
//
// 五层从内到外:建连(SDK) → 空响应(SDK) → 同 provider 安全窗口 → 轮询熔断 → 意图重跑。
// 前三层跟着端点走,所以每个模型配置都能覆盖全局默认;后两层是进程级的,只有全局一份。
//
// 所有输入都遵循同一套「留空 = 不配置」语义,与后端 db.RetryRule 一致:
// 次数 空/0 = 用内置默认 | -1 = 关闭这层重试 | >0 = 用这个次数
// 间隔 空/0 = 用这层原本的指数退避 | >0 = 改用这个固定毫秒间隔
import * as React from "react";
import { Loader2Icon, SaveIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/lib/api";
import type { LLMRetryOverride, LLMRetryPolicy, LLMRetryRule } from "@/lib/types";
export const ZERO_RULE: LLMRetryRule = { attempts: 0, interval_ms: 0 };
export const ZERO_OVERRIDE: LLMRetryOverride = {
connect: ZERO_RULE,
empty: ZERO_RULE,
stream: ZERO_RULE,
};
const ZERO_POLICY: LLMRetryPolicy = {
...ZERO_OVERRIDE,
breaker: ZERO_RULE,
intent: ZERO_RULE,
};
// 每层的「次数留空时的默认值」,仅用于输入框占位符。其余文案(标题 / 触发 / 说明等)
// 走 i18n(llmPage.retryLayer.<层>.*),按运行时语言渲染。
export const RETRY_LAYERS = {
connect: { defAttempts: 3 },
empty: { defAttempts: 2 },
stream: { defAttempts: 2 },
breaker: { defAttempts: 3 },
intent: { defAttempts: 2 },
} satisfies Record<string, { defAttempts: number }>;
type LayerKey = keyof typeof RETRY_LAYERS;
/** 毫秒的人话,只用于在输入框旁边回显,免得数零。 */
function humanMs(ms: number) {
if (!Number.isFinite(ms) || ms <= 0) return "";
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${Number((ms / 1000).toFixed(2))}s`;
return `${Number((ms / 60_000).toFixed(2))}min`;
}
/** 受控数字输入:空串 ↔ 0,中间态("-"、"1e")原样留在本地,不打扰父级。 */
function NumField({
id,
value,
onChange,
placeholder,
min,
}: {
id: string;
value: number;
onChange: (n: number) => void;
placeholder: string;
min: number;
}) {
const [text, setText] = React.useState(value === 0 ? "" : String(value));
// 父级换了一整套值(读取到策略、切换配置)时跟上;自己敲字时不会走到这里,
// 因为那时 value 已经等于本地文本 parse 后的结果。
React.useEffect(() => {
const incoming = value === 0 ? "" : String(value);
setText((cur) => (Number(cur || 0) === value ? cur : incoming));
}, [value]);
return (
<Input
id={id}
type="number"
min={min}
className="w-28 shrink-0"
value={text}
placeholder={placeholder}
onChange={(e) => {
setText(e.target.value);
const n = Number(e.target.value);
onChange(e.target.value.trim() === "" || !Number.isFinite(n) ? 0 : Math.trunc(n));
}}
/>
);
}
/** 一层重试的两个旋钮。idPrefix 用来在同一页出现多次时保住 label 的 htmlFor。 */
export function RetryRuleFields({
layer,
idPrefix,
value,
onChange,
compact,
}: {
layer: LayerKey;
idPrefix: string;
value: LLMRetryRule;
onChange: (r: LLMRetryRule) => void;
/** true = 配置抽屉里的紧凑版:省掉展开说明,只留「什么错误会走到这层」这一句 */
compact?: boolean;
}) {
const t = useTranslations("llmPage");
const meta = RETRY_LAYERS[layer];
const human = humanMs(value.interval_ms);
return (
<div className={compact ? "grid gap-2" : "grid gap-3 rounded-lg border p-3"}>
<div className="grid gap-0.5">
<div className="flex flex-wrap items-baseline gap-2">
<Label className="text-sm">{t(`retryLayer.${layer}.title`)}</Label>
<span className="text-muted-foreground text-xs">{t(`retryLayer.${layer}.where`)}</span>
</div>
{/* 哪些错误会走到这层,具体到状态码——填了旋钮却看不到效果,多半是错误压根不落在这层。 */}
<p className="text-muted-foreground text-xs">
<span className="font-medium text-foreground">{t("retry.triggerLabel")}</span>:{" "}
{t(`retryLayer.${layer}.trigger`)}
</p>
{!compact && (
<p className="text-muted-foreground text-xs">
<span className="font-medium text-foreground">{t("retry.skipsLabel")}</span>:{" "}
{t(`retryLayer.${layer}.skips`)}
</p>
)}
{!compact && <p className="text-muted-foreground text-xs">{t(`retryLayer.${layer}.desc`)}</p>}
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="flex items-center gap-2">
<Label htmlFor={`${idPrefix}-${layer}-n`} className="text-muted-foreground text-xs">
{t(`retryLayer.${layer}.attemptsLabel`)}
</Label>
<NumField
id={`${idPrefix}-${layer}-n`}
min={-1}
value={value.attempts}
placeholder={t("retry.defaultN", { n: meta.defAttempts })}
onChange={(n) => onChange({ ...value, attempts: n })}
/>
</div>
<div className="flex items-center gap-2">
<Label htmlFor={`${idPrefix}-${layer}-ms`} className="text-muted-foreground text-xs">
{t("retry.intervalMs")}
</Label>
<NumField
id={`${idPrefix}-${layer}-ms`}
min={0}
value={value.interval_ms}
placeholder={t("retry.defaultBackoff")}
onChange={(n) => onChange({ ...value, interval_ms: n })}
/>
<span className="text-muted-foreground text-xs">
{human ? t("retry.fixed", { human }) : t(`retryLayer.${layer}.defInterval`)}
</span>
</div>
</div>
{!compact && (
<p className="text-muted-foreground text-xs">
{t("retry.emptyLine", { hint: t(`retryLayer.${layer}.offHint`) })}
</p>
)}
</div>
);
}
/** 模型配置抽屉里的三层覆盖(跟着端点走的那三层)。 */
export function ProfileRetryFields({
value,
onChange,
}: {
value: LLMRetryOverride;
onChange: (o: LLMRetryOverride) => void;
}) {
const t = useTranslations("llmPage");
return (
<div className="grid gap-3 rounded-lg border p-3">
<div className="grid gap-0.5">
<Label className="text-sm">{t("retry.override.label")}</Label>
<p className="text-muted-foreground text-xs">{t("retry.override.hint")}</p>
</div>
{(["connect", "empty", "stream"] as const).map((k) => (
<div key={k} className="border-t pt-3 first:border-t-0 first:pt-0">
<RetryRuleFields
compact
layer={k}
idPrefix="pf"
value={value[k]}
onChange={(r) => onChange({ ...value, [k]: r })}
/>
</div>
))}
</div>
);
}
/** 「重试与退避」tab:五层的全局默认值。 */
export function RetryPolicyPanel() {
const t = useTranslations("llmPage");
const [policy, setPolicy] = React.useState<LLMRetryPolicy>(ZERO_POLICY);
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const load = React.useCallback(async () => {
setLoading(true);
try {
const p = await api.llmRetryPolicy();
setPolicy({ ...ZERO_POLICY, ...p });
} catch (e) {
toast.error(t("retry.toast.loadFailed", { msg: (e as Error).message }));
} finally {
setLoading(false);
}
}, [t]);
React.useEffect(() => {
void load();
}, [load]);
async function save() {
if (saving) return;
setSaving(true);
try {
// 后端会把越界值夹回区间并回传,直接用回传值刷新,所见即所存。
const saved = await api.saveLLMRetryPolicy(policy);
setPolicy({ ...ZERO_POLICY, ...saved });
toast.success(t("retry.toast.saved"));
} catch (e) {
toast.error(t("retry.toast.saveFailed", { msg: (e as Error).message }));
} finally {
setSaving(false);
}
}
const set = (k: LayerKey) => (r: LLMRetryRule) => setPolicy((p) => ({ ...p, [k]: r }));
if (loading) {
return (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-10 text-muted-foreground text-sm">
<Loader2Icon className="size-4 animate-spin" /> {t("retry.loading")}
</div>
);
}
return (
<div className="grid gap-4">
<div className="rounded-lg border bg-muted/30 p-3 text-muted-foreground text-xs leading-relaxed">
{t.rich("retry.intro", {
chain: (c) => <span className="text-foreground">{c}</span>,
emph: (c) => <span className="text-foreground">{c}</span>,
})}
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{(Object.keys(RETRY_LAYERS) as LayerKey[]).map((k) => (
<RetryRuleFields key={k} layer={k} idPrefix="gl" value={policy[k]} onChange={set(k)} />
))}
</div>
<div className="flex gap-2">
<Button onClick={save} disabled={saving}>
{saving ? <Loader2Icon className="animate-spin" /> : <SaveIcon />}
{t("retry.save")}
</Button>
<Button variant="outline" onClick={() => setPolicy(ZERO_POLICY)} disabled={saving}>
{t("retry.resetAll")}
</Button>
</div>
</div>
);
}
+966
View File
@@ -0,0 +1,966 @@
"use client";
import * as React from "react";
import {
Loader2Icon,
PlugZapIcon,
PlusIcon,
RefreshCwIcon,
RotateCcwIcon,
SaveIcon,
StarIcon,
Trash2Icon,
ZapIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api";
import type { LLMPoolMember, LLMPoolStatus, LLMProfile, LLMRetryOverride } from "@/lib/types";
import { cn } from "@/lib/utils";
import { ProfileRetryFields, RetryPolicyPanel, ZERO_OVERRIDE } from "./_components/retry";
// 思考开关(thinking.type)与思考强度(reasoning_effort)是两个【互相独立】的字段,
// 各自单独设置——有些接口没有 thinking 字段、只靠强度参数就能激活思考,故需解耦。
// 存库空字符串 = 该字段【不发送】;Radix Select 不接受空 value,故 UI 用 "none"
// 哨兵表示不发送,存取时与 "" 互转(NONE / fromStore / toStore)。
const NONE = "none";
const fromStore = (v?: string) => (v ? v : NONE);
const toStore = (v: string) => (v === NONE ? "" : v);
function cooldownText(secs: number) {
if (secs <= 0) return "";
if (secs < 60) return `${secs}s`;
return `${Math.ceil(secs / 60)}min`;
}
// 一个配置在卡片上显示的「是否正常」。没填 Key 的配置根本发不出请求,比熔断更该先说;
// 其余状态来自轮询的熔断记录(轮询关着时不会产生新记录,此时「正常」= 没有已知故障)。
// 文案走 i18n(llmPage.health.*),所以把 t 传进来由调用方注入。
type Health = { label: string; cls: string; hint?: string };
// ─────────────────────────────────────────────────────────────────────────────
// 轮询配置抽屉
// ─────────────────────────────────────────────────────────────────────────────
function PoolSheet({
open,
onOpenChange,
pool,
onReload,
}: {
open: boolean;
onOpenChange: (o: boolean) => void;
pool: LLMPoolStatus | null;
onReload: () => Promise<void>;
}) {
const t = useTranslations("llmPage");
const [busy, setBusy] = React.useState(false);
// 冷却倒计时是后端算出的剩余秒数——抽屉开着且有配置不正常时才定时拉,让它走起来。
React.useEffect(() => {
if (!open || !pool?.enabled || !pool.chain.some((m) => m.state !== "ok")) return;
const t = setInterval(() => void onReload(), 10_000);
return () => clearInterval(t);
}, [open, pool, onReload]);
async function toggle(patch: { llm_pool_enabled?: boolean; llm_pool_bind_fallback?: boolean }) {
if (busy) return;
setBusy(true);
try {
await api.setSettings(patch);
await onReload();
if (patch.llm_pool_enabled !== undefined) {
toast.success(patch.llm_pool_enabled ? t("pool.toast.enabled") : t("pool.toast.disabled"));
} else {
toast.success(t("pool.toast.fallbackUpdated"));
}
} catch (e) {
toast.error(t("pool.toast.setFailed", { msg: (e as Error).message }));
} finally {
setBusy(false);
}
}
async function recover(id?: string) {
try {
await api.resetLLMPool(id);
await onReload();
toast.success(id ? t("pool.toast.recoveredOne") : t("pool.toast.recoveredAll"));
} catch (e) {
toast.error(t("pool.toast.recoverFailed", { msg: (e as Error).message }));
}
}
const enabled = pool?.enabled ?? false;
const chain = pool?.chain ?? [];
// 参与轮询的成员(排除被标记「不参与轮询」的),顺序即后端实际的尝试顺序。
const inChain = chain.filter((m) => m.active || !m.excluded);
const tripped = chain.filter((m) => m.state === "tripped");
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex flex-col gap-0 p-0 data-[side=right]:sm:max-w-lg">
<SheetHeader className="px-4">
<SheetTitle className="flex items-center gap-2">
<ZapIcon className="size-4" /> {t("pool.title")}
</SheetTitle>
<SheetDescription>{t.rich("pool.desc", { b: (c) => <b>{c}</b> })}</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-6">
<div className="flex items-center justify-between gap-4 rounded-lg border p-3">
<div className="grid gap-0.5">
<Label className="text-sm">{t("pool.enable.label")}</Label>
<p className="text-muted-foreground text-xs">{t("pool.enable.hint")}</p>
</div>
<Switch
checked={enabled}
disabled={busy}
onCheckedChange={(v) => void toggle({ llm_pool_enabled: v })}
aria-label={t("pool.enable.aria")}
/>
</div>
{enabled && (
<>
<div className="flex items-center justify-between gap-4 rounded-lg border p-3">
<div className="grid gap-0.5">
<Label className="text-sm">{t("pool.bindFallback.label")}</Label>
<p className="text-muted-foreground text-xs">{t("pool.bindFallback.hint")}</p>
</div>
<Switch
checked={pool?.bind_fallback ?? false}
disabled={busy}
onCheckedChange={(v) => void toggle({ llm_pool_bind_fallback: v })}
aria-label={t("pool.bindFallback.aria")}
/>
</div>
<Separator />
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label className="text-sm">{t("pool.order.label")}</Label>
{tripped.length > 0 && (
<Button size="sm" variant="ghost" onClick={() => void recover()}>
<RotateCcwIcon /> {t("pool.recoverAll")}
</Button>
)}
</div>
{inChain.length < 2 && (
<p className="text-muted-foreground text-xs">{t("pool.order.tooFew", { n: inChain.length })}</p>
)}
{chain.map((m) => {
const excluded = m.excluded && !m.active;
const order = excluded ? null : inChain.findIndex((x) => x.profile_id === m.profile_id) + 1;
return (
<div
key={m.profile_id}
className={cn(
"grid gap-1 rounded-lg border p-2.5 text-sm",
excluded && "opacity-55",
m.state === "tripped" && "border-destructive/40",
)}
>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="w-5 shrink-0 text-center font-mono text-muted-foreground text-xs">
{order ?? "—"}
</span>
<span className="font-medium">{m.name}</span>
{m.active && (
<Badge variant="outline" className="border-amber-400/50 text-amber-500">
{t("pool.badge.active")}
</Badge>
)}
{excluded && <Badge variant="outline">{t("pool.badge.excluded")}</Badge>}
<div className="ml-auto flex items-center gap-2">
{m.state === "tripped" && m.cooldown_secs > 0 && (
<span className="text-muted-foreground text-xs">
{t("pool.cooldown", { cooldown: cooldownText(m.cooldown_secs) })}
</span>
)}
{m.state === "degraded" && (
<span className="text-muted-foreground text-xs">
{t("pool.consecutiveFails", { fails: m.fails })}
</span>
)}
{m.state !== "ok" && (
<Button
size="icon"
variant="ghost"
className="size-7"
aria-label={t("pool.recoverNow.aria")}
title={t("pool.recoverNow.title")}
onClick={() => void recover(m.profile_id)}
>
<RotateCcwIcon className="size-3.5" />
</Button>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-x-3 pl-7 text-muted-foreground text-xs">
<code className="truncate font-mono">{m.model}</code>
{!m.active && <span>{t("pool.priority", { priority: m.priority })}</span>}
</div>
{m.last_error && (
<p className="truncate pl-7 font-mono text-muted-foreground text-xs" title={m.last_error}>
{m.last_error}
</p>
)}
</div>
);
})}
{chain.length === 0 && (
<div className="rounded-lg border border-dashed p-4 text-center text-muted-foreground text-sm">
{t("pool.empty")}
</div>
)}
</div>
<div className="rounded-lg border border-dashed p-3 text-muted-foreground text-xs leading-relaxed">
{t("pool.note")}
</div>
</>
)}
</div>
</SheetContent>
</Sheet>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 模型配置抽屉(新建 / 编辑共用同一套表单)
// ─────────────────────────────────────────────────────────────────────────────
function ProfileSheet({
profile,
open,
onOpenChange,
onSaved,
}: {
profile: LLMProfile | null; // null = 新建
open: boolean;
onOpenChange: (o: boolean) => void;
onSaved: (id: string) => void;
}) {
const t = useTranslations("llmPage");
const isNew = !profile;
const [name, setName] = React.useState("");
const [format, setFormat] = React.useState<"anthropic" | "openai" | "openai-responses">("anthropic");
const [model, setModel] = React.useState("");
const [baseUrl, setBaseUrl] = React.useState("");
const [proxy, setProxy] = React.useState("");
const [apiKey, setApiKey] = React.useState("");
const [keyHint, setKeyHint] = React.useState("");
const [rps, setRps] = React.useState("0");
const [rpm, setRpm] = React.useState("0");
const [cw, setCw] = React.useState("0"); // 上下文窗口(K tokens);0=默认200K
const [thinkingType, setThinkingType] = React.useState(NONE);
const [effort, setEffort] = React.useState(NONE);
const [priority, setPriority] = React.useState("0"); // 轮询顺位;越大越先
const [poolExclude, setPoolExclude] = React.useState(false);
const [streaming, setStreaming] = React.useState(true); // true=流式(默认);false=非流式
const [maxTokens, setMaxTokens] = React.useState("0"); // 单次回复输出上限;0=不发送
const [maxTokensField, setMaxTokensField] = React.useState(NONE); // 上限用哪个字段名;NONE=max_tokens
const [sessionHeaderKey, setSessionHeaderKey] = React.useState(""); // 自定义会话头名;空=不发送
const [retry, setRetry] = React.useState<LLMRetryOverride>(ZERO_OVERRIDE); // 本配置的重试覆盖;全 0=跟随全局
const [testing, setTesting] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [models, setModels] = React.useState<string[]>([]);
const [loadingModels, setLoadingModels] = React.useState(false);
const [modelsOpen, setModelsOpen] = React.useState(false);
// 思考开关 / 上限字段名 / 思考强度三组选项的标签走 i18n,故在组件内按当前语言构造。
const THINKING_TYPES: { value: string; label: string }[] = [
{ value: NONE, label: t("profile.thinkingType.opt.none") },
{ value: "disabled", label: t("profile.thinkingType.opt.disabled") },
{ value: "enabled", label: t("profile.thinkingType.opt.enabled") },
];
const MAX_TOKENS_FIELDS: { value: string; label: string }[] = [
{ value: NONE, label: t("profile.maxTokensField.opt.default") },
{ value: "max_completion_tokens", label: "max_completion_tokens" },
];
const EFFORT_LEVELS: { value: string; label: string }[] = [
{ value: NONE, label: t("profile.effort.opt.none") },
{ value: "low", label: "low" },
{ value: "medium", label: "medium" },
{ value: "high", label: "high" },
{ value: "xhigh", label: "xhigh" },
{ value: "max", label: "max" },
];
// 每次打开时从传入的 profile 灌一遍表单(新建则重置为默认值)。抽屉关掉再打开
// 就是一次干净的开始,不会留下上一个配置的残影。
React.useEffect(() => {
if (!open) return;
setName(profile?.name ?? "");
setFormat(profile?.format === "openai" || profile?.format === "openai-responses" ? profile.format : "anthropic");
setModel(profile?.model ?? "");
setBaseUrl(profile?.base_url ?? "");
setProxy(profile?.proxy ?? "");
setRps(String(profile?.rate_per_second ?? 0));
setRpm(String(profile?.rate_per_minute ?? 0));
setCw(String(profile?.context_window_k ?? 0));
setThinkingType(fromStore(profile?.thinking_type));
setEffort(fromStore(profile?.reasoning_effort));
setPriority(String(profile?.priority ?? 0));
setPoolExclude(profile?.pool_exclude ?? false);
setStreaming(profile?.streaming ?? true);
setMaxTokens(String(profile?.max_tokens ?? 0));
setMaxTokensField(fromStore(profile?.max_tokens_field));
setSessionHeaderKey(profile?.session_header_key ?? "");
setRetry(profile?.retry ?? ZERO_OVERRIDE);
setApiKey("");
setKeyHint(profile?.api_key_hint ?? "");
setModels([]);
setModelsOpen(false);
}, [open, profile]);
const profileId = profile ? Number(profile.id) : undefined;
async function loadModels() {
if (loadingModels) return;
setLoadingModels(true);
setModels([]);
try {
const r = await api.fetchLLMModels(format, baseUrl, apiKey, proxy, profileId);
if (r.ok && r.models && r.models.length > 0) {
setModels(r.models);
setModelsOpen(true);
toast.success(t("profile.toast.modelsLoaded", { n: r.models.length }));
} else {
toast.error(t("profile.toast.loadModelsFailed", { error: r.error ?? t("profile.toast.noModels") }));
}
} catch (e) {
toast.error(t("profile.toast.loadModelsError", { msg: (e as Error).message }));
} finally {
setLoadingModels(false);
}
}
async function testConnection() {
if (testing) return;
setTesting(true);
try {
// 用配置实际会跑的思考参数来测,这样不支持该字段的模型在这里就失败,
// 而不是等到跑任务时才炸。传 profile id:Key 输入框留空时用已存的 Key。
const r = await api.testLLM(
format,
model,
baseUrl,
apiKey,
proxy,
toStore(thinkingType),
toStore(effort),
profileId,
streaming,
sessionHeaderKey.trim(),
);
// 回复内容一并展示:看得见模型确实说了话,才算和会话里跑通是一回事。
if (r.ok)
toast.success(t("profile.toast.testOk", { latency: r.latency_ms ?? "?", model: r.model ?? model }), {
description: r.reply ? t("profile.toast.testReply", { reply: r.reply }) : undefined,
});
else toast.error(t("profile.toast.testFailed", { error: r.error ?? t("profile.toast.unknown") }));
} catch (e) {
toast.error(t("profile.toast.testError", { msg: (e as Error).message }));
} finally {
setTesting(false);
}
}
async function save() {
if (!name.trim() || !model.trim()) {
toast.error(t("profile.toast.nameModelRequired"));
return;
}
if (saving) return;
setSaving(true);
try {
const { id } = await api.saveLLMProfile({
...(profile ? { id: Number(profile.id) } : {}),
name: name.trim(),
format,
model: model.trim(),
base_url: baseUrl.trim(),
proxy: proxy.trim(),
api_key: apiKey,
rate_per_second: Number(rps) || 0,
rate_per_minute: Number(rpm) || 0,
context_window_k: Number(cw) || 0,
thinking_type: toStore(thinkingType),
reasoning_effort: toStore(effort),
priority: Number(priority) || 0,
pool_exclude: poolExclude,
streaming,
max_tokens: Math.max(0, Number(maxTokens) || 0),
// 字段名开关只对 openai(Chat Completions) 有意义,其它格式一律回落到默认;
// 后端也会再做一次同样的归一化,这里只是别让 UI 送出自相矛盾的值。
max_tokens_field: format === "openai" ? toStore(maxTokensField) : "",
session_header_key: sessionHeaderKey.trim(),
retry,
});
if (isNew) toast.success(t("profile.toast.created", { name: name.trim() }));
else toast.success(profile?.is_default ? t("profile.toast.savedActive") : t("profile.toast.saved"));
onSaved(String(id));
onOpenChange(false);
} catch (e) {
toast.error(t("profile.toast.saveFailed", { msg: (e as Error).message }));
} finally {
setSaving(false);
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex flex-col gap-0 p-0 data-[side=right]:min-w-[420px] data-[side=right]:sm:max-w-xl"
>
<SheetHeader className="px-4">
<SheetTitle className="flex items-center gap-2">
{isNew ? t("profile.titleNew") : t("profile.titleEdit", { name: profile?.name ?? "" })}
{profile?.is_default && (
<Badge variant="outline" className="border-amber-400/50 text-amber-500">
{t("profile.activeBadge")}
</Badge>
)}
</SheetTitle>
<SheetDescription>{isNew ? t("profile.descNew") : t("profile.descEdit")}</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="p-name">{t("profile.name.label")}</Label>
<Input
id="p-name"
placeholder={t("profile.name.placeholder")}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label>{t("profile.format.label")}</Label>
<Select value={format} onValueChange={(v) => setFormat(v as "anthropic" | "openai" | "openai-responses")}>
<SelectTrigger>
<SelectValue placeholder={t("profile.format.placeholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="anthropic">Anthropic</SelectItem>
<SelectItem value="openai">OpenAI (Chat Completions)</SelectItem>
<SelectItem value="openai-responses">OpenAI (Responses API)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="p-model">{t("profile.model.label")}</Label>
<div className="flex gap-2">
<Input
id="p-model"
className="font-mono"
placeholder="claude-opus-4-8"
value={model}
onChange={(e) => setModel(e.target.value)}
/>
{/* modal: 这个 Popover 的内容被 portal 到 <body>,在 Sheet 的滚动锁之外,
不加 modal 时列表能渲染却滚不动。modal 让它自己持有最上层滚动锁。 */}
<Popover open={modelsOpen} onOpenChange={setModelsOpen} modal>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0"
disabled={loadingModels}
onClick={loadModels}
title={t("profile.model.loadTitle")}
>
{loadingModels ? <Loader2Icon className="animate-spin" /> : <RefreshCwIcon />}
</Button>
</PopoverTrigger>
{models.length > 0 && (
<PopoverContent className="max-h-72 w-72 gap-0 overflow-y-auto overscroll-contain p-1" align="end">
{models.map((m) => (
<button
key={m}
type="button"
className="w-full shrink-0 rounded-md px-2 py-1.5 text-left font-mono text-xs hover:bg-accent hover:text-accent-foreground"
onClick={() => {
setModel(m);
setModelsOpen(false);
}}
>
{m}
</button>
))}
</PopoverContent>
)}
</Popover>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="p-base-url">{t("profile.baseUrl.label")}</Label>
<Input
id="p-base-url"
className="font-mono"
placeholder="https://api.openai.com/v1"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="p-proxy">{t("profile.proxy.label")}</Label>
<Input
id="p-proxy"
className="font-mono"
placeholder="socks5://user:pass@127.0.0.1:1080 · http://127.0.0.1:8080"
value={proxy}
onChange={(e) => setProxy(e.target.value)}
/>
<p className="text-muted-foreground text-xs">{t("profile.proxy.hint")}</p>
</div>
<div className="grid gap-2">
<Label htmlFor="p-session-header">{t("profile.sessionHeader.label")}</Label>
<Input
id="p-session-header"
className="font-mono"
placeholder={t("profile.sessionHeader.placeholder")}
value={sessionHeaderKey}
onChange={(e) => setSessionHeaderKey(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
{t.rich("profile.sessionHeader.hint", { b: (c) => <b>{c}</b> })}
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="p-api-key">API Key</Label>
<Input
id="p-api-key"
type="password"
placeholder={keyHint ? t("profile.apiKey.placeholderSet", { hint: keyHint }) : "sk-…"}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="p-rps">{t("profile.rps")}</Label>
<Input id="p-rps" type="number" min={0} value={rps} onChange={(e) => setRps(e.target.value)} />
</div>
<div className="grid gap-2">
<Label htmlFor="p-rpm">{t("profile.rpm")}</Label>
<Input id="p-rpm" type="number" min={0} value={rpm} onChange={(e) => setRpm(e.target.value)} />
</div>
<div className="grid gap-2">
<Label htmlFor="p-cw">{t("profile.cw")}</Label>
<Input
id="p-cw"
type="number"
min={0}
max={1000}
value={cw}
onChange={(e) => setCw(e.target.value)}
placeholder="200"
/>
</div>
</div>
<p className="-mt-2 text-muted-foreground text-xs">{t("profile.rateHint")}</p>
<div className="grid gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between gap-4">
<div className="grid gap-0.5">
<Label htmlFor="p-priority" className="text-sm">
{t("profile.priority.label")}
</Label>
<p className="text-muted-foreground text-xs">{t("profile.priority.hint")}</p>
</div>
<Input
id="p-priority"
type="number"
className="w-24 shrink-0"
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
</div>
<div className="flex items-center justify-between gap-4 border-t pt-3">
<div className="grid gap-0.5">
<Label className="text-sm">{t("profile.exclude.label")}</Label>
<p className="text-muted-foreground text-xs">{t("profile.exclude.hint")}</p>
</div>
<Switch checked={poolExclude} onCheckedChange={setPoolExclude} aria-label={t("profile.exclude.aria")} />
</div>
<div className="flex items-center justify-between gap-4 border-t pt-3">
<div className="grid gap-0.5">
<Label className="text-sm">{t("profile.streaming.label")}</Label>
<p className="text-muted-foreground text-xs">{t("profile.streaming.hint")}</p>
</div>
<Switch checked={streaming} onCheckedChange={setStreaming} aria-label={t("profile.streaming.aria")} />
</div>
</div>
<div className="grid gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between gap-4">
<div className="grid gap-0.5">
<Label htmlFor="p-max-tokens" className="text-sm">
{t("profile.maxTokens.label")}
</Label>
<p className="text-muted-foreground text-xs">{t("profile.maxTokens.hint")}</p>
</div>
<Input
id="p-max-tokens"
type="number"
min={0}
className="w-28 shrink-0"
value={maxTokens}
onChange={(e) => setMaxTokens(e.target.value)}
placeholder="0"
/>
</div>
<div className="flex items-center justify-between gap-4 border-t pt-3">
<div className="grid gap-0.5">
<Label className="text-sm">{t("profile.maxTokensField.label")}</Label>
<p className="text-muted-foreground text-xs">{t(`profile.maxTokensFieldHint.${format}`)}</p>
</div>
<Select
value={format === "openai" ? maxTokensField : NONE}
onValueChange={setMaxTokensField}
disabled={format !== "openai"}
>
<SelectTrigger className="w-56 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MAX_TOKENS_FIELDS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between gap-4">
<div className="grid gap-0.5">
<Label className="text-sm">{t("profile.thinkingType.label")}</Label>
<p className="text-muted-foreground text-xs">{t("profile.thinkingType.hint")}</p>
</div>
<Select value={thinkingType} onValueChange={setThinkingType}>
<SelectTrigger className="w-32 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{THINKING_TYPES.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between gap-4 border-t pt-3">
<div className="grid gap-0.5">
<Label className="text-sm">{t("profile.effort.label")}</Label>
<p className="text-muted-foreground text-xs">{t("profile.effort.hint")}</p>
</div>
<Select value={effort} onValueChange={setEffort}>
<SelectTrigger className="w-32 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EFFORT_LEVELS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<ProfileRetryFields value={retry} onChange={setRetry} />
</div>
<div className="flex gap-2 border-t px-4 py-3">
<Button variant="outline" onClick={testConnection} disabled={testing}>
{testing ? <Loader2Icon className="animate-spin" /> : <PlugZapIcon />}
{testing ? t("profile.testing") : t("profile.testConnection")}
</Button>
<Button onClick={save} disabled={saving} className="flex-1">
{saving && <Loader2Icon className="animate-spin" />}
{!saving && (isNew ? <PlusIcon /> : <SaveIcon />)}
{isNew ? t("profile.create") : t("profile.save")}
</Button>
</div>
</SheetContent>
</Sheet>
);
}
// ─────────────────────────────────────────────────────────────────────────────
export default function LLMPage() {
const t = useTranslations("llmPage");
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
const [pool, setPool] = React.useState<LLMPoolStatus | null>(null);
const [poolOpen, setPoolOpen] = React.useState(false);
// 抽屉的开关和内容分开存:关闭时 editing 保持不变,否则关闭动画期间标题会从
// 「编辑 X」闪成「新建」。editing = null 表示新建。
const [editOpen, setEditOpen] = React.useState(false);
const [editing, setEditing] = React.useState<LLMProfile | null>(null);
const openEditor = React.useCallback((p: LLMProfile | null) => {
setEditing(p);
setEditOpen(true);
}, []);
// 一个配置在卡片上显示的「是否正常」。没填 Key 的配置根本发不出请求,比熔断更该先说。
const healthOf = React.useCallback(
(p: LLMProfile, m?: LLMPoolMember): Health => {
if (!p.api_key_hint) {
return {
label: t("health.noKey.label"),
cls: "border-muted-foreground/40 text-muted-foreground",
hint: t("health.noKey.hint"),
};
}
if (m?.state === "tripped") {
return {
label:
m.cooldown_secs > 0
? t("health.trippedCooldown", { cooldown: cooldownText(m.cooldown_secs) })
: t("health.tripped"),
cls: "border-destructive/50 text-destructive",
hint: m.last_error,
};
}
if (m?.state === "degraded") {
return {
label: t("health.degraded", { fails: m.fails }),
cls: "border-amber-500/50 text-amber-600 dark:text-amber-400",
hint: m.last_error,
};
}
return { label: t("health.ok"), cls: "border-emerald-500/50 text-emerald-600 dark:text-emerald-400" };
},
[t],
);
const loadPool = React.useCallback(async () => {
try {
setPool(await api.llmPool());
} catch {
/* ignore */
}
}, []);
const load = React.useCallback(async () => {
try {
setProfiles(await api.llmProfiles());
} catch {
/* ignore */
}
await loadPool();
}, [loadPool]);
React.useEffect(() => {
void load();
}, [load]);
// 卡片上的健康徽章按 profile id 取轮询状态。
const health = React.useMemo(() => {
const m = new Map<string, LLMPoolMember>();
for (const c of pool?.chain ?? []) m.set(c.profile_id, c);
return m;
}, [pool]);
async function activate(id: string, name: string) {
try {
await api.activateLLMProfile(id);
toast.success(t("page.toast.activated", { name }));
await load();
} catch (e) {
toast.error(t("page.toast.activateFailed", { msg: (e as Error).message }));
}
}
async function remove(p: LLMProfile) {
if (p.is_default) {
toast.error(t("page.toast.cannotDeleteActive"));
return;
}
try {
await api.deleteLLMProfile(p.id);
toast.success(t("page.toast.deleted", { name: p.name }));
await load();
} catch (e) {
toast.error(t("page.toast.deleteFailed", { msg: (e as Error).message }));
}
}
const poolOn = pool?.enabled ?? false;
return (
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="font-semibold text-xl tracking-tight">LLM</h1>
<p className="text-muted-foreground text-sm">{t("page.subtitle")}</p>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setPoolOpen(true)}>
<ZapIcon /> {t("page.poolConfig")}
{poolOn && (
<Badge variant="outline" className="ml-1 border-emerald-500/50 text-emerald-600 dark:text-emerald-400">
{t("page.enabled")}
</Badge>
)}
</Button>
<Button size="sm" variant="outline" onClick={() => openEditor(null)}>
<PlusIcon /> {t("page.new")}
</Button>
</div>
</div>
<Tabs defaultValue="profiles" className="flex-1">
<TabsList>
<TabsTrigger value="profiles">{t("page.tab.profiles")}</TabsTrigger>
<TabsTrigger value="retry">{t("page.tab.retry")}</TabsTrigger>
</TabsList>
<TabsContent value="profiles" className="mt-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{profiles.map((p) => {
const h = healthOf(p, health.get(p.id));
return (
// biome-ignore lint/a11y/useSemanticElements: 卡片内含自己的操作按钮,用原生 <button> 会造成按钮嵌套(非法 HTML)
<Card
key={p.id}
role="button"
tabIndex={0}
onClick={() => openEditor(p)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openEditor(p);
}
}}
className={cn(
"cursor-pointer gap-0 py-4 outline-none transition-colors hover:border-foreground/30",
p.is_default && "border-amber-400/50 bg-amber-400/5",
)}
>
<CardContent className="grid gap-2 px-4">
<div className="flex items-start gap-2">
<StarIcon
className={cn(
"mt-0.5 size-4 shrink-0",
p.is_default ? "fill-amber-400 text-amber-400" : "text-muted-foreground",
)}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-medium text-sm">{p.name}</span>
<Badge variant="outline" className="uppercase">
{p.format}
</Badge>
<Badge variant="outline" className={cn("ml-auto", h.cls)} title={h.hint}>
{h.label}
</Badge>
</div>
<code className="mt-1 block truncate font-mono text-muted-foreground text-xs">{p.model}</code>
</div>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 pl-6 text-muted-foreground text-xs">
{p.api_key_hint && <span>{p.api_key_hint}</span>}
<span>
{p.rate_per_second}/s · {p.rate_per_minute}/min
</span>
{p.proxy && <span className="truncate">{t("card.proxy", { proxy: p.proxy })}</span>}
{p.reasoning_effort && (
<span>
{t("card.thinking", {
effort: p.reasoning_effort === "off" ? t("card.thinkingOff") : p.reasoning_effort,
})}
</span>
)}
{/* 轮询相关的两个字段只在轮询开着时才有意义,关着时不占版面 */}
{poolOn &&
!p.is_default &&
(p.pool_exclude ? (
<span>{t("card.excluded")}</span>
) : (
<span>{t("card.priority", { priority: p.priority ?? 0 })}</span>
))}
</div>
<div className="mt-1 flex gap-2">
<Button
size="sm"
variant="outline"
className="flex-1"
disabled={p.is_default}
onClick={(e) => {
e.stopPropagation();
void activate(p.id, p.name);
}}
>
{p.is_default ? t("card.active") : t("card.activate")}
</Button>
<Button
size="icon"
variant="outline"
aria-label={t("card.deleteAria")}
onClick={(e) => {
e.stopPropagation();
void remove(p);
}}
>
<Trash2Icon className="text-destructive" />
</Button>
</div>
</CardContent>
</Card>
);
})}
{profiles.length === 0 && (
<div className="col-span-full rounded-lg border border-dashed p-10 text-center text-muted-foreground text-sm">
{t("page.empty")}
</div>
)}
</div>
</TabsContent>
<TabsContent value="retry" className="mt-4">
<RetryPolicyPanel />
</TabsContent>
</Tabs>
<ProfileSheet profile={editing} open={editOpen} onOpenChange={setEditOpen} onSaved={() => void load()} />
<PoolSheet open={poolOpen} onOpenChange={setPoolOpen} pool={pool} onReload={loadPool} />
</div>
);
}
+271
View File
@@ -0,0 +1,271 @@
"use client";
import * as React from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { sseUrl } from "@/lib/api";
import { MOCK } from "@/lib/mock/enabled";
import type { LogLine } from "@/lib/types";
import { cn } from "@/lib/utils";
// Mock 데모: 백엔드 SSE 가 없으므로 예시 로그 몇 줄을 채워 둔다.
const MOCK_LOGS: LogLine[] = [
{
seq: 1,
ts: "2026-07-26T03:55:00Z",
level: "info",
tag: "engine",
text: "ARTEX v0.1.0 backend listening on :8787 (workers=3)",
},
{
seq: 2,
ts: "2026-07-26T03:55:01Z",
level: "info",
tag: "config",
text: "LLM configured from DB: anthropic / claude-opus-4-8",
},
{
seq: 3,
ts: "2026-07-26T03:56:10Z",
level: "info",
tag: "planner",
text: "task t-acme-web: 계획 3라운드, 의도 i-4 생성",
},
{
seq: 4,
ts: "2026-07-26T03:57:00Z",
level: "warn",
tag: "guard",
text: "block bash: 범위 이탈, out.evil.example 는 scope 밖",
},
{
seq: 5,
ts: "2026-07-26T03:57:30Z",
level: "info",
tag: "work#1",
text: "report_finding: Default Credentials (high) 저장 완료",
},
{
seq: 6,
ts: "2026-07-26T03:58:20Z",
level: "error",
tag: "work#3",
text: "intercept: mysqldump 파괴적 규칙 적중, 사람 승인 대기",
},
];
const levelTone: Record<LogLine["level"], string> = {
info: "text-foreground/80",
warn: "text-amber-600 dark:text-amber-400",
error: "text-red-600 dark:text-red-400",
};
const levelDot: Record<LogLine["level"], string> = {
info: "bg-muted-foreground/40",
warn: "bg-amber-500",
error: "bg-red-500",
};
function fmtTime(ts: string) {
const d = new Date(ts);
if (isNaN(d.getTime())) return "";
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
export default function LogsPage() {
const t = useTranslations("logsPage");
const [lines, setLines] = React.useState<LogLine[]>([]);
const [q, setQ] = React.useState("");
const [level, setLevel] = React.useState<"all" | LogLine["level"]>("all");
const [paused, setPaused] = React.useState(false);
const [loadingHistory, setLoadingHistory] = React.useState(false);
const [hasMore, setHasMore] = React.useState(false);
const bottom = React.useRef<HTMLDivElement>(null);
const stick = React.useRef(true);
const pausedRef = React.useRef(false);
pausedRef.current = paused;
// Minimum db_id seen — used as the cursor for loading older history.
const minDbId = React.useMemo(() => {
let min = 0;
for (const l of lines) {
if (l.db_id && (min === 0 || l.db_id < min)) min = l.db_id;
}
return min;
}, [lines]);
// Live tail via SSE. on connect, replays whatever is in the ring (includes
// DB-restored history from the last 100 rows written before restart).
React.useEffect(() => {
if (MOCK) {
setLines(MOCK_LOGS);
return;
}
const es = new EventSource(sseUrl("/api/logs/stream?since=0"));
es.onmessage = (e) => {
if (pausedRef.current) return;
try {
const l = JSON.parse(e.data) as LogLine;
setLines((prev) => (prev.some((x) => x.seq === l.seq) ? prev : [...prev, l].slice(-3000)));
} catch {
/* ignore malformed frame */
}
};
return () => es.close();
}, []);
// Mark "has more" once we have any db_id in view.
React.useEffect(() => {
if (minDbId > 1) setHasMore(true);
}, [minDbId]);
async function loadOlderHistory() {
if (loadingHistory) return;
setLoadingHistory(true);
try {
const params = minDbId > 0 ? `?before=${minDbId}&limit=200` : `?limit=200`;
const res = await fetch(`/api/logs/history${params}`);
if (!res.ok) return;
const data = (await res.json()) as { items: LogLine[]; has_more: boolean };
if (data.items?.length) {
// Assign synthetic seq numbers below current minimum to keep dedup working.
setLines((prev) => {
const minSeq = prev.reduce((m, l) => Math.min(m, l.seq ?? 0), 0);
const older = data.items.map((l, i) => ({
...l,
seq: minSeq - data.items.length + i,
}));
// Prepend, dedup by db_id, cap at 5000.
const merged = [...older, ...prev];
const seen = new Set<number>();
const deduped = merged.filter((l) => {
if (!l.db_id) return true;
if (seen.has(l.db_id)) return false;
seen.add(l.db_id);
return true;
});
return deduped.slice(-5000);
});
}
setHasMore(data.has_more ?? false);
} finally {
setLoadingHistory(false);
}
}
const filtered = React.useMemo(() => {
const needle = q.trim().toLowerCase();
return lines.filter(
(l) =>
(level === "all" || l.level === level) &&
(!needle || l.text.toLowerCase().includes(needle) || l.tag.toLowerCase().includes(needle)),
);
}, [lines, q, level]);
// biome-ignore lint/correctness/useExhaustiveDependencies: filtered changes intentionally re-run the auto-scroll to the newest line.
React.useEffect(() => {
if (stick.current && !paused) bottom.current?.scrollIntoView();
}, [filtered, paused]);
const counts = React.useMemo(() => {
let warn = 0,
error = 0;
for (const l of lines) {
if (l.level === "warn") warn++;
else if (l.level === "error") error++;
}
return { warn, error, total: lines.length };
}, [lines]);
return (
<div className="flex flex-1 flex-col gap-3">
<div>
<h1 className="text-xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground text-sm">{t("subtitle")}</p>
</div>
<div className="flex flex-1 flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder={t("filterPlaceholder")}
value={q}
onChange={(e) => setQ(e.target.value)}
className="h-8 max-w-xs"
/>
<div className="flex gap-1">
{(["all", "info", "warn", "error"] as const).map((lv) => (
<Button
key={lv}
size="sm"
variant={level === lv ? "default" : "outline"}
className="h-8"
onClick={() => setLevel(lv)}
>
{lv === "all" ? t("all") : lv}
</Button>
))}
</div>
<Button
size="sm"
variant={paused ? "default" : "outline"}
className="h-8"
onClick={() => setPaused((p) => !p)}
>
{paused ? t("paused") : t("pause")}
</Button>
<Button size="sm" variant="outline" className="h-8" onClick={() => setLines([])}>
{t("clear")}
</Button>
<span className="ml-auto text-xs text-muted-foreground">
{t("totalLines", { count: counts.total })} ·{" "}
<span className="text-amber-600 dark:text-amber-400">{t("warnCount", { count: counts.warn })}</span> ·{" "}
<span className="text-red-600 dark:text-red-400">{t("errorCount", { count: counts.error })}</span>
</span>
</div>
<div
onScroll={(e) => {
const el = e.currentTarget;
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}}
className="h-[calc(100vh-14rem)] overflow-auto rounded-lg border bg-card p-2 font-mono text-xs leading-relaxed"
>
{hasMore && (
<div className="flex justify-center py-2">
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
disabled={loadingHistory}
onClick={loadOlderHistory}
>
{loadingHistory ? t("loading") : t("loadOlder")}
</Button>
</div>
)}
{filtered.length === 0 ? (
<p className="py-10 text-center text-muted-foreground">{t("empty")}</p>
) : (
filtered.map((l) => {
const body =
l.tag && l.text.startsWith("[" + l.tag + "]") ? l.text.slice(l.tag.length + 2).trimStart() : l.text;
return (
<div key={l.seq} className="flex items-start gap-2 px-1 py-0.5 hover:bg-muted/40">
<span className={cn("mt-1.5 size-1.5 shrink-0 rounded-full", levelDot[l.level])} />
<span className="shrink-0 tabular-nums text-muted-foreground">{fmtTime(l.ts)}</span>
{l.tag && (
<span className="shrink-0 rounded bg-muted px-1 text-[10px] text-foreground/70">{l.tag}</span>
)}
<span className={cn("min-w-0 whitespace-pre-wrap break-words", levelTone[l.level])}>{body}</span>
</div>
);
})
)}
<div ref={bottom} />
</div>
</div>
</div>
);
}
+468
View File
@@ -0,0 +1,468 @@
"use client";
import * as React from "react";
import { PlusIcon, RefreshCwIcon, ServerIcon, Trash2Icon } from "lucide-react";
import { useTranslations } from "next-intl";
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 { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import type { Agent, MCPServer, MCPTool } from "@/lib/types";
type Transport = "stdio" | "http" | "sse";
type FormState = {
name: string;
transport: Transport;
command: string;
args: string;
url: string;
env: string;
insecure: boolean;
};
const emptyForm: FormState = {
name: "",
transport: "stdio",
command: "",
args: "",
url: "",
env: "",
insecure: false,
};
export default function MCPPage() {
const t = useTranslations("mcpPage");
const [servers, setServers] = React.useState<MCPServer[]>([]);
const [agents, setAgents] = React.useState<Agent[]>([]);
const [visibility, setVisibility] = React.useState<Record<number, string[]>>({});
const [open, setOpen] = React.useState(false);
const [editing, setEditing] = React.useState<MCPServer | null>(null); // null = add mode
const [tab, setTab] = React.useState<"config" | "tools">("config");
const [form, setForm] = React.useState<FormState>(emptyForm);
const [saving, setSaving] = React.useState(false);
const [tools, setTools] = React.useState<MCPTool[]>([]);
const [toolsLoading, setToolsLoading] = React.useState(false);
const [refreshing, setRefreshing] = React.useState(false);
const load = React.useCallback(() => {
api
.agents()
.then(setAgents)
.catch(() => {
/* 조회 실패는 무시하고 기본 상태를 유지한다 */
});
api
.mcpServers()
.then((ss) => {
setServers(ss);
ss.forEach((s) => {
void api
.resourceVisibility("mcp", s.id)
.then((ids) => setVisibility((v) => ({ ...v, [s.id]: ids })))
.catch(() => {
/* 가시성 조회 실패는 무시한다 */
});
});
})
.catch(() => {
/* 조회 실패는 무시하고 기본 상태를 유지한다 */
});
}, []);
React.useEffect(() => {
load();
}, [load]);
function setF(patch: Partial<FormState>) {
setForm((f) => ({ ...f, ...patch }));
}
function parseEnv(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of text.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const idx = trimmed.indexOf("=");
if (idx > 0) {
const key = trimmed.slice(0, idx).trim();
if (key) out[key] = trimmed.slice(idx + 1).trim();
}
}
return out;
}
function envToText(env: Record<string, string> | undefined): string {
return Object.entries(env ?? {})
.map(([k, v]) => `${k}=${v}`)
.join("\n");
}
function openAdd() {
setEditing(null);
setForm(emptyForm);
setTools([]);
setTab("config");
setOpen(true);
}
function openEdit(s: MCPServer) {
setEditing(s);
setForm({
name: s.name,
transport: s.transport,
command: s.command ?? "",
args: (s.args ?? []).join(" "),
url: s.url ?? "",
env: envToText(s.env),
insecure: s.insecure ?? false,
});
setTab("config");
setOpen(true);
void loadTools(s.id);
}
async function loadTools(id: number) {
setToolsLoading(true);
try {
setTools(await api.mcpTools(id));
} catch {
setTools([]);
} finally {
setToolsLoading(false);
}
}
async function saveForm() {
if (!form.name.trim()) {
toast.error(t("errNoName"));
return;
}
if (form.transport === "stdio" && !form.command.trim()) {
toast.error(t("errNoCmd"));
return;
}
if (form.transport !== "stdio" && !form.url.trim()) {
toast.error(t("errNoUrl"));
return;
}
setSaving(true);
try {
const base =
form.transport !== "stdio"
? {
transport: form.transport,
url: form.url.trim(),
command: "",
args: [] as string[],
env: parseEnv(form.env), // 远程模式下 env 即请求头
insecure: form.insecure,
}
: {
transport: "stdio" as const,
command: form.command.trim(),
args: form.args.trim() ? form.args.trim().split(/\s+/) : [],
env: parseEnv(form.env),
insecure: false,
};
await api.saveMcpServer({
...(editing ? { id: editing.id } : {}),
name: form.name.trim(),
enabled: editing ? editing.enabled : true,
...base,
});
toast.success(editing ? t("saved") : t("added"));
if (!editing) setOpen(false);
load();
} catch (e) {
toast.error(t("saveFailed", { msg: (e as Error).message }));
} finally {
setSaving(false);
}
}
async function refreshTools() {
if (!editing) return;
setRefreshing(true);
try {
const found = await api.refreshMcpServer(editing.id);
setTools(found);
toast.success(t("foundTools", { count: found.length }));
load();
} catch (e) {
toast.error(t("refreshFailed", { msg: (e as Error).message }));
} finally {
setRefreshing(false);
}
}
async function removeServer(s: MCPServer) {
try {
await api.deleteMcpServer(s.id);
toast.success(t("removed", { name: s.name }));
setOpen(false);
load();
} catch (e) {
toast.error(t("deleteFailed", { msg: (e as Error).message }));
}
}
async function toggleEnabled(s: MCPServer) {
try {
await api.saveMcpServer({ ...s, enabled: !s.enabled });
load();
} catch (e) {
toast.error(t("actionFailed", { msg: (e as Error).message }));
}
}
async function toggleVisibility(serverId: number, agentId: string, agentName: string) {
const on = (visibility[serverId] ?? []).includes(agentId);
try {
await api.toggleVisibility(agentId, "mcp", serverId, !on);
toast.success(on ? t("visibilityRevoked", { name: agentName }) : t("visibilityGranted", { name: agentName }));
load();
} catch (e) {
toast.error(t("actionFailed", { msg: (e as Error).message }));
}
}
function renderForm() {
return (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>{t("transport")}</Label>
<div className="flex gap-2">
<Button
type="button"
variant={form.transport === "stdio" ? "default" : "outline"}
onClick={() => setF({ transport: "stdio" })}
>
{t("transportStdio")}
</Button>
<Button
type="button"
variant={form.transport === "http" ? "default" : "outline"}
onClick={() => setF({ transport: "http" })}
>
{t("transportHttp")}
</Button>
<Button
type="button"
variant={form.transport === "sse" ? "default" : "outline"}
onClick={() => setF({ transport: "sse" })}
>
{t("transportSse")}
</Button>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="m-name">{t("nameLabel")}</Label>
<Input
id="m-name"
placeholder="filesystem"
value={form.name}
onChange={(e) => setF({ name: e.target.value })}
/>
</div>
{form.transport === "stdio" ? (
<>
<div className="grid gap-2">
<Label htmlFor="m-cmd">{t("cmdLabel")}</Label>
<Input
id="m-cmd"
className="font-mono"
placeholder="npx"
value={form.command}
onChange={(e) => setF({ command: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="m-args">{t("argsLabel")}</Label>
<Input
id="m-args"
className="font-mono"
placeholder="-y @modelcontextprotocol/server-filesystem /data"
value={form.args}
onChange={(e) => setF({ args: e.target.value })}
/>
</div>
</>
) : (
<div className="grid gap-2">
<Label htmlFor="m-url">{t("urlLabel")}</Label>
<Input
id="m-url"
className="font-mono"
placeholder="https://mcp.example.com/mcp"
value={form.url}
onChange={(e) => setF({ url: e.target.value })}
/>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={form.insecure} onCheckedChange={(v) => setF({ insecure: v === true })} />
{t("insecureLabel")}
</label>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="m-env">{form.transport !== "stdio" ? t("headersLabel") : t("envLabel")}</Label>
<Textarea
id="m-env"
className="font-mono"
placeholder={form.transport !== "stdio" ? "Authorization=Bearer xxxx" : "API_KEY=xxxx\nFOO=bar"}
value={form.env}
onChange={(e) => setF({ env: e.target.value })}
/>
</div>
</div>
);
}
function renderTools() {
return (
<div className="flex flex-col gap-3 py-4">
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">{t("toolCount", { count: tools.length })}</span>
<Button size="sm" variant="outline" disabled={refreshing} onClick={refreshTools}>
<RefreshCwIcon className={refreshing ? "animate-spin" : ""} /> {t("refresh")}
</Button>
</div>
{toolsLoading ? (
<p className="text-muted-foreground text-sm">{t("loading")}</p>
) : tools.length === 0 ? (
<p className="text-muted-foreground text-sm">{t("toolsEmpty")}</p>
) : (
<div className="flex flex-col divide-y">
{tools.map((t) => (
<div key={t.name} className="py-2.5">
<code className="font-mono text-sm">{t.name}</code>
{t.description && (
<p className="text-muted-foreground mt-0.5 text-xs leading-relaxed">{t.description}</p>
)}
</div>
))}
</div>
)}
</div>
);
}
return (
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div>
<h1 className="text-xl font-semibold tracking-tight">MCP</h1>
<p className="text-muted-foreground text-sm">{t("subtitle")}</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<button
type="button"
onClick={openAdd}
className="text-foreground/70 border-foreground/70 hover:bg-muted/60 hover:shadow-sm flex min-h-[116px] flex-col items-center justify-center gap-2 rounded-xl border border-dashed transition"
>
<PlusIcon className="size-6" />
<span className="text-sm">{t("addCard")}</span>
</button>
{servers.map((s) => (
<Card
key={s.id}
onClick={() => openEdit(s)}
className="hover:border-primary/60 cursor-pointer gap-3 transition hover:shadow-sm"
>
<CardHeader>
<div className="flex items-center gap-2">
<ServerIcon className="text-muted-foreground size-4 shrink-0" />
<CardTitle className="truncate text-base">{s.name}</CardTitle>
<Badge variant="outline" className="uppercase">
{s.transport}
</Badge>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 부모 카드로의 클릭 전파만 차단하는 컨테이너다. 이 div 자체는 상호작용 요소가 아니고, 안쪽 Switch·삭제 버튼은 각각 키보드로 접근된다 */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: 위와 같이 클릭 전파 차단 전용이라 별도 키보드 핸들러가 필요 없다 */}
<div className="ml-auto flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<Switch checked={s.enabled} onCheckedChange={() => toggleEnabled(s)} aria-label={t("enableAria")} />
<Button size="icon" variant="outline" aria-label={t("deleteAria")} onClick={() => removeServer(s)}>
<Trash2Icon className="text-destructive" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="grid gap-3">
<p className="text-muted-foreground text-sm">
{s.tools && s.tools.length > 0 ? t("toolCount", { count: s.tools.length }) : t("noTools")}
</p>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 체크박스 영역의 클릭 전파만 차단하는 컨테이너다. 이 div 자체는 상호작용 요소가 아니고, 안쪽 체크박스는 각각 키보드로 접근된다 */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: 위와 같이 클릭 전파 차단 전용이라 별도 키보드 핸들러가 필요 없다 */}
<div className="grid gap-2" onClick={(e) => e.stopPropagation()}>
<span className="text-muted-foreground text-xs">{t("visibilityLabel")}</span>
<div className="flex flex-wrap gap-x-4 gap-y-2">
{agents.map((a) => (
<label key={a.key} className="flex items-center gap-2 text-sm">
<Checkbox
checked={(visibility[s.id] ?? []).includes(a.id)}
onCheckedChange={() => toggleVisibility(s.id, a.id, a.name)}
/>
{a.name}
</label>
))}
</div>
</div>
</CardContent>
</Card>
))}
</div>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="right" className="w-full data-[side=right]:sm:max-w-lg">
<SheetHeader>
<SheetTitle>{editing ? editing.name : t("addServer")}</SheetTitle>
<SheetDescription>{t("sheetDesc")}</SheetDescription>
</SheetHeader>
{editing ? (
<Tabs
value={tab}
onValueChange={(v) => setTab(v as "config" | "tools")}
className="flex min-h-0 flex-1 flex-col px-4"
>
<TabsList>
<TabsTrigger value="config">{t("tabConfig")}</TabsTrigger>
<TabsTrigger value="tools">
{t("tabTools")}
{tools.length ? ` (${tools.length})` : ""}
</TabsTrigger>
</TabsList>
<TabsContent value="config" className="min-h-0 flex-1 overflow-y-auto">
{renderForm()}
<div className="flex gap-2 pt-2 pb-6">
<Button onClick={saveForm} disabled={saving}>
{t("save")}
</Button>
</div>
</TabsContent>
<TabsContent value="tools" className="min-h-0 flex-1 overflow-y-auto">
{renderTools()}
</TabsContent>
</Tabs>
) : (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
{renderForm()}
<div className="pt-2 pb-6">
<Button onClick={saveForm} disabled={saving}>
<PlusIcon /> {t("add")}
</Button>
</div>
</div>
)}
</SheetContent>
</Sheet>
</div>
);
}
@@ -0,0 +1,188 @@
// 渠道字段表与配置值的解析工具。
//
// 与页面拆开是因为这一份是**数据**而不是视图:它描述每种渠道有哪些字段、
// 各自该用什么控件,以及表单文本到配置值(JSON)的双向转换。
// 单独放一个文件后,新增渠道只需要动这里,页面本身不必改。
//
// 本文件是纯数据模块(非 client component),不能调用 useTranslations。
// 所以文案不写死在这里,而是存 i18n 键(notifyPage 命名空间),由渲染它们的
// 组件(page.tsx / channel-form.tsx)用 t() 解析——键是 ASCII,上游中文原文
// 保留在 messages/zh.json。渠道类型展示名改走 notifyPage.kind.<kind>。
// 各渠道的配置字段定义。
//
// 这里刻意保留一份前端字段表,而不是让后端下发 schema:后端只负责
// Validate(必填/格式),UI 需要的是布局与控件类型,两者关注的不是同一件事。
// 唯一的耦合点是 secret_keys —— 哪些字段该渲染成密码框由后端给出,
// 因为只有渠道实现自己清楚哪些值算凭据(企业微信的整个 Webhook 就是凭据,
// 而钉钉的只是其中一个 secret)。新增渠道时这里少一个条目只会让表单变空白,
// 不会静默出错(下面的 hasFields 会提示)。
//
// label / help / options[].label 存的是 notifyPage 命名空间下的 i18n 键,
// placeholder 保留字面值(URL、示例,不翻译)。
export type FieldKind = "text" | "password" | "number" | "select" | "textarea" | "switch" | "kv" | "list";
export interface FieldDef {
key: string;
/** notifyPage 命名空间下的 i18n 键 */
label: string;
kind: FieldKind;
placeholder?: string;
/** notifyPage 命名空间下的 i18n 键 */
help?: string;
/** options[].label 也是 i18n 键 */
options?: { value: string; label: string }[];
}
export const CHANNEL_FIELDS: Record<string, FieldDef[]> = {
dingtalk: [
{
key: "webhook",
label: "field.dingtalk.webhook.label",
kind: "text",
placeholder: "https://oapi.dingtalk.com/robot/send?access_token=...",
},
{
key: "secret",
label: "field.dingtalk.secret.label",
kind: "password",
help: "field.dingtalk.secret.help",
},
],
feishu: [
{
key: "webhook",
label: "field.feishu.webhook.label",
kind: "text",
placeholder: "https://open.feishu.cn/open-apis/bot/v2/hook/...",
},
{ key: "secret", label: "field.feishu.secret.label", kind: "password", help: "field.feishu.secret.help" },
],
wecom: [
{
key: "webhook",
label: "field.wecom.webhook.label",
kind: "text",
placeholder: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...",
},
],
webhook: [
{
key: "url",
label: "field.webhook.url.label",
kind: "text",
placeholder: "https://your-endpoint.example.com/hook",
},
{
key: "method",
label: "field.webhook.method.label",
kind: "select",
options: [
{ value: "POST", label: "field.webhook.method.opt.POST" },
{ value: "PUT", label: "field.webhook.method.opt.PUT" },
{ value: "PATCH", label: "field.webhook.method.opt.PATCH" },
{ value: "GET", label: "field.webhook.method.opt.GET" },
],
},
{ key: "headers", label: "field.webhook.headers.label", kind: "kv", help: "field.webhook.headers.help" },
{
key: "body_template",
label: "field.webhook.body_template.label",
kind: "textarea",
help: "field.webhook.body_template.help",
},
],
telegram: [
{ key: "bot_token", label: "field.telegram.bot_token.label", kind: "password", placeholder: "123456:ABC-DEF..." },
{ key: "chat_id", label: "field.telegram.chat_id.label", kind: "text", placeholder: "-1001234567890" },
{
key: "base_url",
label: "field.telegram.base_url.label",
kind: "text",
placeholder: "https://api.telegram.org",
help: "field.telegram.base_url.help",
},
],
email: [
{ key: "host", label: "field.email.host.label", kind: "text", placeholder: "smtp.example.com" },
{
key: "port",
label: "field.email.port.label",
kind: "number",
placeholder: "587",
help: "field.email.port.help",
},
{ key: "username", label: "field.email.username.label", kind: "text" },
{ key: "password", label: "field.email.password.label", kind: "password" },
{ key: "from", label: "field.email.from.label", kind: "text", placeholder: "artex@example.com" },
{ key: "to", label: "field.email.to.label", kind: "list", help: "field.email.to.help" },
{ key: "tls", label: "field.email.tls.label", kind: "switch", help: "field.email.tls.help" },
],
};
// value 是过滤用的 min_severity 值,label 是 notifyPage.severityOpt.* 的 i18n 键。
export const SEVERITY_OPTIONS = [
{ value: "", label: "severityOpt.all" },
{ value: "low", label: "severityOpt.low" },
{ value: "medium", label: "severityOpt.medium" },
{ value: "high", label: "severityOpt.high" },
{ value: "critical", label: "severityOpt.critical" },
];
export type ChannelForm = {
name: string;
kind: string;
mode: "realtime" | "digest";
enabled: boolean;
ratePerMin: string;
config: Record<string, unknown>;
minSeverity: string;
includeText: string;
excludeText: string;
taskIDsText: string;
assetIDsText: string;
onStatusChange: boolean;
};
export const emptyForm = (kind: string): ChannelForm => ({
name: "",
kind,
mode: "realtime",
enabled: true,
ratePerMin: "",
config: {},
minSeverity: "",
includeText: "",
excludeText: "",
taskIDsText: "",
assetIDsText: "",
onStatusChange: false,
});
// parseKV 解析「每行 KEY=VALUE」的文本域。
export function parseKV(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of text.split("\n")) {
const t = line.trim();
if (!t) continue;
const i = t.indexOf("=");
if (i > 0) out[t.slice(0, i).trim()] = t.slice(i + 1).trim();
}
return out;
}
// parseIDs 解析逗号/空白分隔的 id 列表。分隔符保留全角逗号「,」:
// 用户可能从别处粘贴带全角逗号的列表,这是输入解析逻辑而非展示文案。
export function parseIDs(text: string): number[] {
return text
.split(/[\s,,]+/)
.map((s) => s.trim())
.filter(Boolean)
.map((s) => Number(s))
.filter((n) => Number.isFinite(n) && n > 0);
}
// parseKeywords 解析行/逗号分隔的关键词列表(漏洞类型名可能含空格,所以按行或逗号切)。
// 同样保留全角逗号「,」作为分隔符(输入解析,非展示)。
export function parseKeywords(text: string): string[] {
return text
.split(/[\n,,]+/)
.map((s) => s.trim())
.filter(Boolean);
}
@@ -0,0 +1,160 @@
"use client";
import { CheckIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import type { NotificationFilter } from "@/lib/types";
// asText / inputType 是本文件内的取值辅助(与控件渲染强相关),不放在 channel-fields。
import { type FieldDef, type FieldKind, SEVERITY_OPTIONS } from "./channel-fields";
// asText 把任意配置值渲染成输入框可用的字符串。
// config 来自 JSON,值可能是 string / number / boolean / array / null,
// 这里只关心「能不能塞进文本框」,具体序列化由 buildConfig 负责。
function asText(v: unknown): string {
if (typeof v === "string") return v;
if (v === null || v === undefined) return "";
return String(v);
}
// inputType 把字段类型映射到 input 的 type 属性。
function inputType(kind: FieldKind): "text" | "password" | "number" {
if (kind === "password") return "password";
if (kind === "number") return "number";
return "text";
}
// ConfigField 按字段定义渲染对应的控件。
//
// 掩码字段的处理是这里唯一的讲究:输入框**不显示**掩码值本身,只显示一行
// 「已保存」提示。这样界面上就只有一个规则——框里有字就是用户填的,
// 空框就是空值。若把 "__masked__:…abc123" 塞进输入框,用户会以为那是要自己
// 删掉的占位文本,反而更容易误清凭据。
//
// def.label / def.help / def.options[].label 是 notifyPage 命名空间的 i18n 键,
// 由这里用 t() 解析(见 channel-fields.ts)。
export function ConfigField({
def,
value,
isSecret,
onChange,
}: {
def: FieldDef;
value: unknown;
isSecret: boolean;
onChange: (v: unknown) => void;
}) {
const t = useTranslations("notifyPage");
const id = `n-cfg-${def.key}`;
const raw = asText(value);
// 后端回显的掩码值:形如 "__masked__:…abc123",尾部是原值的可辨识片段。
const masked = isSecret && raw.startsWith("__masked__");
const maskedTail = masked ? (raw.split("…")[1] ?? "") : "";
if (def.kind === "switch") {
return (
<div className="flex items-center gap-2 text-sm">
<Switch checked={value === true} onCheckedChange={onChange} aria-label={t(def.label)} />
{t(def.label)}
{def.help && <span className="text-muted-foreground">({t(def.help)})</span>}
</div>
);
}
if (def.kind === "select") {
return (
<div className="grid gap-2">
<Label>{t(def.label)}</Label>
<Select value={raw || def.options?.[0]?.value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(def.options ?? []).map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
// 控件按字段类型分派。用 if 链而不是嵌套三元,是因为这里要区分四种控件,
// 三层三元读起来已经要停下来数括号了。
function control() {
if (def.kind === "textarea" || def.kind === "kv") {
return (
<Textarea
id={id}
className="font-mono"
placeholder={def.placeholder}
value={masked ? "" : raw}
onChange={(e) => onChange(e.target.value)}
/>
);
}
if (def.kind === "list") {
return (
<Input
id={id}
value={Array.isArray(value) ? (value as string[]).join(", ") : raw}
onChange={(e) => onChange(e.target.value)}
placeholder={def.placeholder}
/>
);
}
return (
<Input
id={id}
className={def.kind === "text" ? "font-mono" : ""}
type={inputType(def.kind)}
placeholder={def.placeholder}
value={masked ? "" : raw}
onChange={(e) => onChange(e.target.value)}
/>
);
}
const hint = masked ? (
<p className="text-muted-foreground flex items-center gap-1 text-xs">
<CheckIcon className="size-3" />
{maskedTail ? t("masked.savedTail", { tail: maskedTail }) : t("masked.saved")}
</p>
) : (
def.help && <p className="text-muted-foreground text-xs">{t(def.help)}</p>
);
return (
<div className="grid gap-2">
<Label htmlFor={id}>{t(def.label)}</Label>
{control()}
{hint}
</div>
);
}
// FilterSummary 把过滤条件摘要成一行,让卡片不用展开就能看出这个渠道推什么。
export function FilterSummary({ filter }: { filter: NotificationFilter }) {
const t = useTranslations("notifyPage");
const parts: string[] = [];
if (filter.min_severity) {
const opt = SEVERITY_OPTIONS.find((o) => o.value === filter.min_severity);
parts.push(opt ? t(opt.label) : filter.min_severity);
}
if (filter.vulnclass_include?.length) parts.push(t("summary.includeCount", { n: filter.vulnclass_include.length }));
if (filter.vulnclass_exclude?.length) parts.push(t("summary.excludeCount", { n: filter.vulnclass_exclude.length }));
if (filter.task_ids?.length) parts.push(t("summary.taskCount", { n: filter.task_ids.length }));
if (filter.asset_ids?.length) parts.push(t("summary.assetCount", { n: filter.asset_ids.length }));
if (filter.on_status_change) parts.push(t("summary.statusChange"));
if (parts.length === 0) {
return <p className="text-muted-foreground text-sm">{t("summary.all")}</p>;
}
return <p className="text-muted-foreground text-sm">{parts.join(" · ")}</p>;
}
@@ -0,0 +1,187 @@
"use client";
import * as React from "react";
import { RefreshCwIcon, RotateCcwIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { StatusBadge } from "@/components/status-badge";
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api } from "@/lib/api";
import type { NotificationChannel, NotificationDelivery } from "@/lib/types";
// DeliveryList 是投递记录表:可按渠道与状态筛选,失败项可手动重发。
export function DeliveryList({ channels }: { channels: NotificationChannel[] }) {
const t = useTranslations("notifyPage");
// 전송 상태·심각도 라벨은 전역 공유 `status` 네임스페이스에서 온다(StatusBadge 와 동일 출처).
// lib/status.ts 의 중국어는 상류 대조용 죽은 폴백이라 직접 쓰지 않는다(턴 129 함정 참조).
const ts = useTranslations("status");
const tp = useTranslations("pagination");
const [rows, setRows] = React.useState<NotificationDelivery[]>([]);
const [total, setTotal] = React.useState(0);
const [page, setPage] = React.useState(1);
const [channelID, setChannelID] = React.useState<number | undefined>(undefined);
const [state, setState] = React.useState<string | undefined>(undefined);
const [loading, setLoading] = React.useState(false);
const pageSize = 50;
const load = React.useCallback(() => {
setLoading(true);
api
.notifyDeliveries({ channelId: channelID, state, page, pageSize })
.then((r) => {
setRows(r.deliveries);
setTotal(r.total);
})
.catch((e) => toast.error(t("delivery.loadFailed", { msg: (e as Error).message })))
.finally(() => setLoading(false));
// t 는 안정 참조라 deps 에 넣지 않아도 되지만, exhaustive-deps 를 만족시키려 포함.
}, [channelID, state, page, t]);
React.useEffect(() => {
load();
}, [load]);
async function retry(id: number) {
try {
await api.notifyRetryDelivery(id);
toast.success(t("delivery.requeued"));
load();
} catch (e) {
toast.error(t("delivery.resendFailed", { msg: (e as Error).message }));
}
}
const maxPage = Math.max(1, Math.ceil(total / pageSize));
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<Select
value={channelID ? String(channelID) : "all"}
onValueChange={(v) => {
setPage(1);
setChannelID(v === "all" ? undefined : Number(v));
}}
>
<SelectTrigger size="sm" className="w-44">
<SelectValue placeholder={t("delivery.allChannels")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("delivery.allChannels")}</SelectItem>
{channels.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={state ?? "all"}
onValueChange={(v) => {
setPage(1);
setState(v === "all" ? undefined : v);
}}
>
<SelectTrigger size="sm" className="w-32">
<SelectValue placeholder={t("delivery.allStates")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("delivery.allStates")}</SelectItem>
{["pending", "sending", "sent", "failed", "skipped"].map((s) => (
<SelectItem key={s} value={s}>
{ts(`delivery.${s}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="sm" variant="outline" onClick={load} disabled={loading}>
<RefreshCwIcon className={loading ? "animate-spin" : ""} /> {t("delivery.refresh")}
</Button>
<span className="text-muted-foreground ml-auto text-xs">{t("delivery.totalCount", { total })}</span>
</div>
<Card className="py-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-40">{t("delivery.colTime")}</TableHead>
<TableHead>{t("delivery.colFinding")}</TableHead>
<TableHead className="w-40">{t("delivery.colChannel")}</TableHead>
<TableHead className="w-24">{t("delivery.colState")}</TableHead>
<TableHead className="w-16">{t("delivery.colAttempts")}</TableHead>
<TableHead>{t("delivery.colError")}</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-muted-foreground py-8 text-center">
{loading ? t("delivery.loading") : t("delivery.empty")}
</TableCell>
</TableRow>
) : (
rows.map((d) => (
<TableRow key={d.id}>
<TableCell className="text-muted-foreground text-xs whitespace-nowrap">
{formatTime(d.created_at)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<StatusBadge domain="severity" value={d.severity} />
<span className="truncate text-sm">{d.title || t("delivery.noTitle")}</span>
{d.event_kind === "finding_status_changed" && (
<Badge variant="outline" className="shrink-0">
{t("delivery.statusChanged")}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-sm">{d.channel_name}</TableCell>
<TableCell>
<StatusBadge domain="delivery" value={d.state} />
</TableCell>
<TableCell className="text-muted-foreground text-sm">{d.attempts}</TableCell>
<TableCell className="text-muted-foreground max-w-md text-xs break-all">{d.last_error}</TableCell>
<TableCell>
{/* 只有失败/跳过的才给重发入口:已送达的重发会造成重复推送。 */}
{(d.state === "failed" || d.state === "skipped") && (
<Button size="sm" variant="outline" onClick={() => retry(d.id)}>
<RotateCcwIcon /> {t("delivery.resend")}
</Button>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</Card>
{maxPage > 1 && (
<div className="flex items-center justify-end gap-2">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{tp("prev")}
</Button>
<span className="text-muted-foreground text-sm">
{page} / {maxPage}
</span>
<Button size="sm" variant="outline" disabled={page >= maxPage} onClick={() => setPage((p) => p + 1)}>
{tp("next")}
</Button>
</div>
)}
</div>
);
}
function formatTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString("ko-KR", { hour12: false });
}
@@ -0,0 +1,27 @@
import type { useTranslations } from "next-intl";
import { Card, CardContent } from "@/components/ui/card";
// 本文件被 client 页面引用,但自身是纯展示/纯函数,不直接调用 useTranslations。
// formatBacklog 需要 i18n 单位文案,所以由调用方把 t 注入进来(tasks/approval-records 先例)。
type Translator = ReturnType<typeof useTranslations>;
export function StatTile({ label, value, hint, tone }: { label: string; value: string; hint?: string; tone?: string }) {
return (
<Card size="sm" className="gap-1">
<CardContent>
<p className="text-muted-foreground text-xs">{label}</p>
<p className={`text-lg font-semibold ${tone === "red" ? "text-rose-600" : ""}`}>{value}</p>
{hint && <p className={`text-xs ${tone === "red" ? "text-rose-600" : "text-muted-foreground"}`}>{hint}</p>}
</CardContent>
</Card>
);
}
// formatBacklog 把积压毫秒数渲染成人看得懂的量级。
export function formatBacklog(ms: number, t: Translator): string {
if (!ms) return "—";
if (ms < 60_000) return t("unit.sec", { n: Math.round(ms / 1000) });
if (ms < 3_600_000) return t("unit.min", { n: Math.round(ms / 60_000) });
return t("unit.hour", { n: (ms / 3_600_000).toFixed(1) });
}
+607
View File
@@ -0,0 +1,607 @@
"use client";
import * as React from "react";
import { BellIcon, PlusIcon, SendIcon, Trash2Icon } from "lucide-react";
import { useTranslations } from "next-intl";
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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import type { NotificationChannel, NotificationFilter, NotificationMeta } from "@/lib/types";
import {
CHANNEL_FIELDS,
type ChannelForm,
emptyForm,
parseIDs,
parseKeywords,
parseKV,
SEVERITY_OPTIONS,
} from "./_components/channel-fields";
import { ConfigField, FilterSummary } from "./_components/channel-form";
import { DeliveryList } from "./_components/delivery-list";
import { formatBacklog, StatTile } from "./_components/stat-tile";
// 本页只负责编排:加载数据、维护表单状态、调用接口。
// 字段定义与解析在 _components/channel-fields.ts,控件与过滤摘要在
// _components/channel-form.tsx,投递记录在 _components/delivery-list.tsx——
// 拆开是因为它们各自能被单独读懂,而挤在一个文件里时这个页面接近 1100 行。
export default function NotifyPage() {
const t = useTranslations("notifyPage");
// 渠道类型展示名:已知的 6 种走 i18n(notifyPage.kind.<kind>),
// 后端若回传未知类型则原样显示该 key(保留原来的 `?? ch.kind` 语义)。
const kindLabel = (k: string) => (k in CHANNEL_FIELDS ? t(`kind.${k}`) : k);
const [meta, setMeta] = React.useState<NotificationMeta | null>(null);
const [channels, setChannels] = React.useState<NotificationChannel[]>([]);
const [tab, setTab] = React.useState<"channels" | "deliveries">("channels");
const [open, setOpen] = React.useState(false);
const [editing, setEditing] = React.useState<NotificationChannel | null>(null);
const [form, setForm] = React.useState<ChannelForm>(emptyForm("dingtalk"));
const [saving, setSaving] = React.useState(false);
const [testing, setTesting] = React.useState(false);
const [globalSaving, setGlobalSaving] = React.useState(false);
const [baseURL, setBaseURL] = React.useState("");
const [digestMin, setDigestMin] = React.useState("");
const load = React.useCallback(() => {
api
.notifyMeta()
.then((m) => {
setMeta(m);
setBaseURL(m.public_base_url);
setDigestMin(m.digest_interval_min);
})
.catch((e) => toast.error(t("toast.loadConfigFailed", { msg: (e as Error).message })));
// 渠道列表加载失败要报出来:静默失败会显示成「一个渠道都没有」,
// 用户会以为配置丢了,比直接报错更让人慌。
api
.notifyChannels()
.then(setChannels)
.catch((e) => toast.error(t("toast.loadChannelsFailed", { msg: (e as Error).message })));
}, [t]);
React.useEffect(() => {
load();
}, [load]);
function setF(patch: Partial<ChannelForm>) {
setForm((f) => ({ ...f, ...patch }));
}
function setCfg(key: string, value: unknown) {
setForm((f) => ({ ...f, config: { ...f.config, [key]: value } }));
}
function openAdd() {
setEditing(null);
setForm(emptyForm(meta?.kinds[0]?.kind ?? "dingtalk"));
setOpen(true);
}
function openEdit(ch: NotificationChannel) {
setEditing(ch);
// filter 在后端是 Go 结构体,永远序列化成对象(不会是 null),所以不需要兜底。
const f = ch.filter;
setForm({
name: ch.name,
kind: ch.kind,
mode: ch.mode,
enabled: ch.enabled,
ratePerMin: String(ch.rate_per_min),
// 后端回显的 config 里凭据是掩码值;原样放进表单,提交时原样送回,
// 后端据此保留库中原值。
config: { ...ch.config },
minSeverity: f.min_severity ?? "",
includeText: (f.vulnclass_include ?? []).join("\n"),
excludeText: (f.vulnclass_exclude ?? []).join("\n"),
taskIDsText: (f.task_ids ?? []).join(","),
assetIDsText: (f.asset_ids ?? []).join(","),
onStatusChange: f.on_status_change ?? false,
});
setOpen(true);
}
// buildConfig 把表单状态转成渠道 config。
//
// 唯一的规则,两类值:
// - 掩码值("__masked__...")原样送回 → 后端解读为「这个字段没改,保留库中原值」
// - 其余一律按用户输入提交,空串即「清空该字段」
//
// 之所以不特殊照顾凭据字段(比如「凭据留空就跳过」),是因为那会让用户**无法清除**
// 一个设错的密钥——界面上没有任何操作能表达「我要把它删掉」。现在的规则下,
// 清空输入框就等于清空该字段,语义唯一且用户可控。
// 掩码值不会出现在输入框里(见 ConfigField),所以「框里有字」永远等于
// 「用户主动填的」。
function buildConfig(): Record<string, unknown> {
const defs = CHANNEL_FIELDS[form.kind] ?? [];
const out: Record<string, unknown> = {};
for (const d of defs) {
const raw = form.config[d.key];
if (d.kind === "switch") {
out[d.key] = raw === true;
continue;
}
if (typeof raw === "string" && raw.startsWith("__masked__")) {
out[d.key] = raw;
continue;
}
if (d.kind === "number") {
const n = Number(raw);
out[d.key] = Number.isFinite(n) && n > 0 ? n : 0;
continue;
}
if (d.kind === "kv") {
out[d.key] = parseKV(String(raw ?? ""));
continue;
}
if (d.kind === "list") {
out[d.key] = String(raw ?? "")
.split(/[\s,,]+/)
.map((s) => s.trim())
.filter(Boolean);
continue;
}
out[d.key] = String(raw ?? "").trim();
}
return out;
}
function buildFilter(): NotificationFilter {
return {
min_severity: form.minSeverity || undefined,
vulnclass_include: parseKeywords(form.includeText),
vulnclass_exclude: parseKeywords(form.excludeText),
task_ids: parseIDs(form.taskIDsText),
asset_ids: parseIDs(form.assetIDsText),
on_status_change: form.onStatusChange,
};
}
async function saveForm() {
if (!form.name.trim()) {
toast.error(t("toast.nameRequired"));
return;
}
setSaving(true);
try {
const payload = {
name: form.name.trim(),
kind: form.kind,
mode: form.mode,
enabled: form.enabled,
config: buildConfig(),
filter: buildFilter(),
rate_per_min: form.ratePerMin.trim() === "" ? undefined : Number(form.ratePerMin),
};
if (editing) {
await api.notifyUpdateChannel(editing.id, payload);
toast.success(t("toast.saved"));
setOpen(false);
} else {
await api.notifyCreateChannel(payload);
toast.success(t("toast.channelAdded"));
setOpen(false);
}
load();
} catch (e) {
toast.error(t("toast.saveFailed", { msg: (e as Error).message }));
} finally {
setSaving(false);
}
}
async function testChannel() {
if (!editing) return;
setTesting(true);
try {
const r = await api.notifyTestChannel(editing.id);
toast.success(t("toast.testSent", { ms: r.latency_ms }));
} catch (e) {
// 后端把渠道返回的原始错误如实回传,这是排查配置的唯一线索,原样展示。
toast.error(t("toast.testFailed", { msg: (e as Error).message }), { duration: 12000 });
} finally {
setTesting(false);
}
}
async function removeChannel(ch: NotificationChannel) {
try {
await api.notifyDeleteChannel(ch.id);
toast.success(t("toast.deleted", { name: ch.name }));
setOpen(false);
load();
} catch (e) {
toast.error(t("toast.deleteFailed", { msg: (e as Error).message }));
}
}
async function toggleEnabled(ch: NotificationChannel) {
try {
await api.notifyUpdateChannel(ch.id, { enabled: !ch.enabled });
load();
} catch (e) {
toast.error(t("toast.actionFailed", { msg: (e as Error).message }));
}
}
async function toggleGlobal(on: boolean) {
setGlobalSaving(true);
try {
await api.setSettings({ notify_enabled: on });
setMeta((m) => (m ? { ...m, enabled: on } : m));
toast.success(on ? t("toast.globalOn") : t("toast.globalOff"));
} catch (e) {
toast.error(t("toast.actionFailed", { msg: (e as Error).message }));
} finally {
setGlobalSaving(false);
}
}
async function saveGlobal() {
setGlobalSaving(true);
try {
const patch: Record<string, unknown> = { notify_public_base_url: baseURL.trim() };
const n = Number(digestMin);
if (Number.isFinite(n) && n > 0) patch.notify_digest_interval_min = n;
await api.setSettings(patch);
toast.success(t("toast.saved"));
load();
} catch (e) {
toast.error(t("toast.saveFailed", { msg: (e as Error).message }));
} finally {
setGlobalSaving(false);
}
}
const fields = CHANNEL_FIELDS[form.kind] ?? [];
const secretKeys = new Set(meta?.kinds.find((k) => k.kind === form.kind)?.secret_keys ?? []);
const defaultRate = meta?.kinds.find((k) => k.kind === form.kind)?.default_rate_per_min ?? 0;
return (
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground text-sm">{t("subtitle")}</p>
</div>
{meta && (
// 用 div 而不是 label:Switch 自带 aria-label,外面再套一层 label
// 既关联不到任何原生控件,又会让点击文字看起来应该能切换。
<div className="flex shrink-0 items-center gap-2 text-sm">
<span className="text-muted-foreground">{t("globalSwitch")}</span>
<Switch
checked={meta.enabled}
disabled={globalSaving}
onCheckedChange={toggleGlobal}
aria-label={t("aria.globalSwitch")}
/>
</div>
)}
</div>
{meta && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
<StatTile
label={t("stats.channels")}
value={`${meta.stats.channels_on} / ${meta.stats.channels}`}
hint={t("stats.channelsHint")}
/>
<StatTile label={t("stats.sentToday")} value={String(meta.stats.sent_today)} />
<StatTile label={t("stats.pending")} value={String(meta.stats.pending)} />
<StatTile
label={t("stats.failed")}
value={String(meta.stats.failed)}
tone={meta.stats.failed > 0 ? "red" : undefined}
/>
<StatTile
label={t("stats.backlog")}
value={formatBacklog(meta.stats.backlog_age_ms, t)}
// 积压年龄比积压条数有用得多:积压 3 条可以是从 3 秒到 3 小时。
hint={meta.stats.backlog_age_ms > 5 * 60_000 ? t("stats.backlogStuck") : undefined}
tone={meta.stats.backlog_age_ms > 5 * 60_000 ? "red" : undefined}
/>
</div>
)}
<Card className="gap-3">
<CardHeader>
<CardTitle className="text-base">{t("global.title")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="n-base">{t("global.baseUrl")}</Label>
<Input
id="n-base"
placeholder="https://artex.example.com"
value={baseURL}
onChange={(e) => setBaseURL(e.target.value)}
/>
<p className="text-muted-foreground text-xs">{t("global.baseUrlHint")}</p>
</div>
<div className="grid gap-2">
<Label htmlFor="n-digest">{t("global.digest")}</Label>
<Input
id="n-digest"
type="number"
min={1}
max={1440}
placeholder="30"
value={digestMin}
onChange={(e) => setDigestMin(e.target.value)}
/>
<p className="text-muted-foreground text-xs">{t("global.digestHint")}</p>
</div>
<div className="sm:col-span-2">
<Button onClick={saveGlobal} disabled={globalSaving}>
{t("global.save")}
</Button>
</div>
</CardContent>
</Card>
<Tabs value={tab} onValueChange={(v) => setTab(v as "channels" | "deliveries")} className="flex flex-col gap-4">
<TabsList>
<TabsTrigger value="channels">{t("tab.channels")}</TabsTrigger>
<TabsTrigger value="deliveries">{t("tab.deliveries")}</TabsTrigger>
</TabsList>
<TabsContent value="channels">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<button
type="button"
onClick={openAdd}
className="text-foreground/70 border-foreground/70 hover:bg-muted/60 hover:shadow-sm flex min-h-[130px] flex-col items-center justify-center gap-2 rounded-xl border border-dashed transition"
>
<PlusIcon className="size-6" />
<span className="text-sm">{t("addChannel")}</span>
</button>
{channels.map((ch) => (
<Card
key={ch.id}
onClick={() => openEdit(ch)}
className="hover:border-primary/60 cursor-pointer gap-3 transition hover:shadow-sm"
>
<CardHeader>
<div className="flex items-center gap-2">
<BellIcon className="text-muted-foreground size-4 shrink-0" />
<CardTitle className="truncate text-base">{ch.name}</CardTitle>
{/* 卡片整体可点(进入编辑),所以这两个控件必须各自吞掉冒泡,
否则开关/删除会顺带触发编辑。把 stopPropagation 挂在控件自己
身上,而不是套一层 div:套 div 会造出一个「看起来可交互但没有
角色」的静态元素,既触发 a11y 告警,语义上也说不通。 */}
<div className="ml-auto flex items-center gap-2">
<Switch
checked={ch.enabled}
onCheckedChange={() => toggleEnabled(ch)}
onClick={(e) => e.stopPropagation()}
aria-label={t("aria.enable")}
/>
<Button
size="icon"
variant="outline"
aria-label={t("aria.delete")}
onClick={(e) => {
e.stopPropagation();
// void 显式丢弃 Promise:removeChannel 自己 catch 并 toast,
// 这里不需要 await(onClick 不是 async)。
void removeChannel(ch);
}}
>
<Trash2Icon className="text-destructive" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="grid gap-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{kindLabel(ch.kind)}</Badge>
<Badge variant="outline">{ch.mode === "digest" ? t("mode.digest") : t("mode.realtime")}</Badge>
{!ch.enabled && <Badge variant="outline">{t("disabled")}</Badge>}
</div>
<FilterSummary filter={ch.filter} />
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="deliveries">
<DeliveryList channels={channels} />
</TabsContent>
</Tabs>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="right" className="w-full data-[side=right]:sm:max-w-lg">
<SheetHeader>
<SheetTitle>{editing ? editing.name : t("sheet.addTitle")}</SheetTitle>
<SheetDescription>
{kindLabel(form.kind)}
{defaultRate > 0 ? t("sheet.rateLimited", { rate: defaultRate }) : t("sheet.rateUnlimited")}
</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>{t("sheet.kindLabel")}</Label>
<Select
value={form.kind}
onValueChange={(v) => {
// 换类型等于换一套凭据字段,不能把旧配置合并进来。
setF({ kind: v, config: {} });
}}
disabled={!!editing}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(meta?.kinds ?? []).map((k) => (
<SelectItem key={k.kind} value={k.kind}>
{kindLabel(k.kind)}
</SelectItem>
))}
</SelectContent>
</Select>
{editing && <p className="text-muted-foreground text-xs">{t("sheet.kindLocked")}</p>}
</div>
<div className="grid gap-2">
<Label htmlFor="n-name">{t("sheet.nameLabel")}</Label>
<Input
id="n-name"
placeholder={t("sheet.namePlaceholder")}
value={form.name}
onChange={(e) => setF({ name: e.target.value })}
/>
</div>
{fields.length === 0 ? (
<p className="text-muted-foreground text-sm">{t("sheet.noFields")}</p>
) : (
fields.map((d) => (
<ConfigField
key={d.key}
def={d}
value={form.config[d.key]}
isSecret={secretKeys.has(d.key)}
onChange={(v) => setCfg(d.key, v)}
/>
))
)}
<div className="grid gap-2">
<Label>{t("sheet.modeLabel")}</Label>
<Select value={form.mode} onValueChange={(v) => setF({ mode: v as "realtime" | "digest" })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="realtime">{t("sheet.modeRealtime")}</SelectItem>
<SelectItem value="digest">{t("sheet.modeDigest")}</SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">{t("sheet.modeHint")}</p>
</div>
<div className="grid gap-2">
<Label htmlFor="n-rate">{t("sheet.rateLabel")}</Label>
<Input
id="n-rate"
type="number"
min={0}
placeholder={defaultRate > 0 ? String(defaultRate) : t("sheet.ratePlaceholder")}
value={form.ratePerMin}
onChange={(e) => setF({ ratePerMin: e.target.value })}
/>
<p className="text-muted-foreground text-xs">{t("sheet.rateHint")}</p>
</div>
<div className="border-t pt-4">
<p className="mb-3 text-sm font-medium">{t("filter.title")}</p>
<div className="grid gap-4">
<div className="grid gap-2">
<Label>{t("filter.minSeverity")}</Label>
<Select
value={form.minSeverity || "all"}
onValueChange={(v) => setF({ minSeverity: v === "all" ? "" : v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEVERITY_OPTIONS.map((o) => (
<SelectItem key={o.value || "all"} value={o.value || "all"}>
{t(o.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="n-inc">{t("filter.includeLabel")}</Label>
<Textarea
id="n-inc"
placeholder={t("filter.includePlaceholder")}
value={form.includeText}
onChange={(e) => setF({ includeText: e.target.value })}
/>
<p className="text-muted-foreground text-xs">{t("filter.includeHint")}</p>
</div>
<div className="grid gap-2">
<Label htmlFor="n-exc">{t("filter.excludeLabel")}</Label>
<Textarea
id="n-exc"
placeholder={t("filter.excludePlaceholder")}
value={form.excludeText}
onChange={(e) => setF({ excludeText: e.target.value })}
/>
<p className="text-muted-foreground text-xs">{t("filter.excludeHint")}</p>
</div>
<div className="grid gap-2">
<Label htmlFor="n-tasks">{t("filter.taskIds")}</Label>
<Input
id="n-tasks"
placeholder="1, 2, 3"
value={form.taskIDsText}
onChange={(e) => setF({ taskIDsText: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="n-assets">{t("filter.assetIds")}</Label>
<Input
id="n-assets"
placeholder="10, 11"
value={form.assetIDsText}
onChange={(e) => setF({ assetIDsText: e.target.value })}
/>
<p className="text-muted-foreground text-xs">{t("filter.idsHint")}</p>
</div>
<div className="flex items-center gap-2 text-sm">
<Switch
checked={form.onStatusChange}
onCheckedChange={(v) => setF({ onStatusChange: v })}
aria-label={t("aria.statusChange")}
/>
{t("filter.onStatusChange")}
</div>
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<Switch
checked={form.enabled}
onCheckedChange={(v) => setF({ enabled: v })}
aria-label={t("aria.enable")}
/>
{t("sheet.enable")}
</div>
</div>
<div className="flex gap-2 pt-2 pb-6">
<Button onClick={saveForm} disabled={saving}>
{editing ? t("sheet.save") : t("sheet.add")}
</Button>
{editing && (
<Button variant="outline" onClick={testChannel} disabled={testing}>
<SendIcon /> {t("sheet.sendTest")}
</Button>
)}
</div>
</div>
</SheetContent>
</Sheet>
</div>
);
}
@@ -0,0 +1,310 @@
"use client";
import * as React from "react";
import {
CheckCircle2Icon,
DownloadIcon,
ExternalLinkIcon,
RefreshCwIcon,
RotateCcwIcon,
TriangleAlertIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { api, sseUrl } from "@/lib/api";
import type { UpdateCheck, UpdateProgress } from "@/lib/types";
/** 等待新版本上线的最长时间。一次升级要经过三次进程启动(暂存 → 换装 → 新版),
* 每次都是秒级,三分钟足够覆盖慢磁盘和 Docker 容器重建。 */
const RESTART_TIMEOUT_MS = 180_000;
function humanSize(n?: number): string {
if (!n || n <= 0) return "";
const units = ["B", "KB", "MB", "GB"];
let v = n;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export function UpdateCard() {
const t = useTranslations("settingsPage");
const richTags = {
code: (chunks: React.ReactNode) => <span className="font-mono">{chunks}</span>,
};
const [info, setInfo] = React.useState<UpdateCheck | null>(null);
const [checking, setChecking] = React.useState(true);
const [progress, setProgress] = React.useState<UpdateProgress | null>(null);
// 与 progress 分开:暂存完成后进程就没了,SSE 会断,此时要切到轮询 /api/health。
const [restarting, setRestarting] = React.useState(false);
const [busy, setBusy] = React.useState(false);
// quiet 同时决定要不要绕过后端缓存:进页面时的自动检查用缓存(顶栏刚查过),
// 用户手动点「检查更新」则强制回源,否则刚发布的版本要等缓存过期才看得到。
const check = React.useCallback(
(quiet = false) => {
setChecking(true);
api
.checkUpdate(!quiet)
.then((r) => {
setInfo(r);
if (!quiet) {
if (r.error) toast.error(t("update.toast.checkFailed", { error: r.error }));
else if (r.has_update) toast.success(t("update.toast.found", { latest: r.latest ?? "" }));
else if (r.comparable) toast.success(t("update.toast.upToDate"));
}
})
.catch((e) => {
if (!quiet) toast.error(t("update.toast.checkFailed", { error: (e as Error).message }));
})
.finally(() => setChecking(false));
},
[t],
);
React.useEffect(() => {
check(true);
}, [check]);
// 轮询 /api/health 直到版本号变化。
//
// 判据必须是"版本变了"而不是"能连上了":换装过程中旧版本会短暂地重新起来一次
// (那一次只负责把 artex.new 换上去然后立刻退出),只看连通性会误判成功。
const waitForNewVersion = React.useCallback(
async (fromVersion: string) => {
setRestarting(true);
const deadline = Date.now() + RESTART_TIMEOUT_MS;
while (Date.now() < deadline) {
await sleep(2000);
try {
const r = await fetch("/api/health", { cache: "no-store" });
if (r.ok) {
const j = (await r.json()) as { version?: string };
if (j.version && j.version !== fromVersion) {
toast.success(t("update.toast.updated", { version: j.version }));
await sleep(800);
window.location.reload();
return;
}
}
} catch {
// 重启窗口内连不上是预期的,继续轮询。
}
}
setRestarting(false);
toast.error(t("update.toast.restartTimeout"));
},
[t],
);
// 订阅更新进度。SSE 不走 Next 的 /api 重写(那层会缓冲,事件推不出来)。
const openStream = React.useCallback(
(fromVersion: string) => {
const es = new EventSource(sseUrl("/api/update/stream"));
es.onmessage = (ev) => {
let p: UpdateProgress;
try {
p = JSON.parse(ev.data) as UpdateProgress;
} catch {
return;
}
setProgress(p);
if (p.phase === "failed") {
es.close();
setBusy(false);
toast.error(t("update.toast.updateFailed", { error: p.error || p.message }));
return;
}
if (p.phase === "staged") {
es.close();
void waitForNewVersion(fromVersion);
}
};
es.onerror = () => {
// 进程退出时 SSE 必然断开。如果已经进入等待重启,这属于正常现象,
// 交给 /api/health 轮询继续判定即可。
es.close();
};
return es;
},
[waitForNewVersion, t],
);
const doUpdate = () => {
if (!info) return;
const from = info.current;
const ok = window.confirm(
`${t("update.confirm.title", { latest: info.latest ?? "" })}\n\n` +
t("update.confirm.restart") +
(info.mode === "docker" ? `\n\n${t("update.confirm.docker")}` : ""),
);
if (!ok) return;
setBusy(true);
setProgress({ phase: "downloading", percent: 0, message: t("update.toast.preparing") });
const es = openStream(from);
api.applyUpdate().catch((e) => {
es.close();
setBusy(false);
setProgress(null);
toast.error(t("update.toast.startFailed", { error: (e as Error).message }));
});
};
const doRollback = () => {
if (!info) return;
if (!window.confirm(t("update.confirm.rollback"))) return;
const from = info.current;
setBusy(true);
api
.rollbackUpdate()
.then(() => {
toast.success(t("update.toast.rollbackSwitched"));
void waitForNewVersion(from);
})
.catch((e) => {
setBusy(false);
toast.error(t("update.toast.rollbackFailed", { error: (e as Error).message }));
});
};
const phase = progress?.phase;
const showProgress = busy || restarting;
// 只有下载阶段拿得到真实百分比(按 Content-Length 算)。校验/解压/等待重启都是
// 时长不可知的阶段,进度条填满并加个脉冲动画表示"在忙但说不准还要多久"。
const downloading = !restarting && phase === "downloading";
const pct = downloading ? Math.max(progress?.percent ?? 0, 0) : 100;
return (
// 设置页是多列瀑布流布局,卡片自己负责行间距并禁止跨列断开(见 page.tsx 的注释)。
<Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<DownloadIcon className="size-4" />
{t("update.title")}
</CardTitle>
<CardDescription>{t("update.desc")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">{t("update.currentVersion")}</span>
<Badge variant="secondary" className="font-mono">
{info?.current ?? "…"}
</Badge>
{info && (
<>
<Badge variant="outline" className="font-mono">
{info.os}/{info.arch}
</Badge>
<Badge variant="outline">
{info.mode === "docker" ? t("update.modeDocker") : t("update.modeStandalone")}
</Badge>
</>
)}
{info?.latest && (
<>
<span className="text-muted-foreground">{t("update.latestVersion")}</span>
<Badge variant={info.has_update ? "default" : "secondary"} className="font-mono">
{info.latest}
</Badge>
</>
)}
{info?.html_url && (
<a
href={info.html_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-4 hover:underline"
>
{t("update.changelog")} <ExternalLinkIcon className="size-3" />
</a>
)}
</div>
{info?.boot_notice && (
<p className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-2 text-xs text-amber-700 dark:text-amber-400">
<TriangleAlertIcon className="mt-0.5 size-3.5 shrink-0" />
{info.boot_notice}
</p>
)}
{info?.error && (
<p className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-2 text-xs text-destructive">
<TriangleAlertIcon className="mt-0.5 size-3.5 shrink-0" />
{t("update.connectError", { error: info.error })}
</p>
)}
{info && !info.comparable && info.reason && <p className="text-xs text-muted-foreground">{info.reason}</p>}
{info?.has_update && info.asset_available === false && (
<p className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-2 text-xs text-destructive">
<TriangleAlertIcon className="mt-0.5 size-3.5 shrink-0" />
{t("update.noAsset", { latest: info.latest ?? "", os: info.os, arch: info.arch, asset: info.asset ?? "" })}
</p>
)}
{info?.has_update && info.asset_available !== false && (
<p className="text-xs text-muted-foreground">
{t("update.willDownloadPrefix")}
<span className="font-mono">{info.asset}</span>
{info.size ? t("update.willDownloadSize", { size: humanSize(info.size) }) : ""}
{t("update.willDownloadSuffix")}
</p>
)}
{info && !info.has_update && info.comparable && !info.error && (
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<CheckCircle2Icon className="size-3.5 text-emerald-600" />
{t("update.upToDate")}
</p>
)}
{info?.mode === "docker" && info.has_update && (
<p className="text-xs text-muted-foreground">{t.rich("update.dockerNote", richTags)}</p>
)}
{showProgress && (
<div className="space-y-1.5">
<Progress value={pct} className={downloading ? undefined : "animate-pulse"} />
<p className="text-xs text-muted-foreground">{restarting ? t("update.restarting") : progress?.message}</p>
</div>
)}
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => check(false)} disabled={checking || busy || restarting}>
<RefreshCwIcon className={checking ? "size-4 animate-spin" : "size-4"} />
{t("update.check")}
</Button>
<Button
size="sm"
onClick={doUpdate}
disabled={busy || restarting || !info?.has_update || info?.asset_available === false}
>
<DownloadIcon className="size-4" />
{info?.has_update ? t("update.updateToVersion", { latest: info.latest ?? "" }) : t("update.updateNow")}
</Button>
{info?.has_backup && (
<Button variant="ghost" size="sm" onClick={doRollback} disabled={busy || restarting}>
<RotateCcwIcon className="size-4" />
{t("update.rollback")}
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">{t.rich("update.footer", richTags)}</p>
</CardContent>
</Card>
);
}
+659
View File
@@ -0,0 +1,659 @@
"use client";
import * as React from "react";
import { CpuIcon, FlaskConicalIcon, KeyboardIcon, RadioTowerIcon, SearchIcon, ShieldAlertIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/lib/api";
import { type ChatSendMode, setChatSendMode, useChatSendMode, useChatSendModeOptions } from "@/lib/chat-send-mode";
import type { Settings } from "@/lib/types";
import { UpdateCard } from "./_components/update-card";
export default function SystemSettingsPage() {
const t = useTranslations("settingsPage");
const richTags = {
b: (chunks: React.ReactNode) => <b>{chunks}</b>,
br: () => <br />,
code: (chunks: React.ReactNode) => <span className="font-mono">{chunks}</span>,
};
const [trafficCapture, setTrafficCapture] = React.useState(false);
const [agentTrafficBinding, setAgentTrafficBinding] = React.useState(false);
const [webSearch, setWebSearch] = React.useState(false);
const [backend, setBackend] = React.useState("ddgs");
const [braveKeySet, setBraveKeySet] = React.useState(false);
const [braveKeyInput, setBraveKeyInput] = React.useState("");
const [tavilyKeySet, setTavilyKeySet] = React.useState(false);
const [tavilyKeyInput, setTavilyKeyInput] = React.useState("");
const [savingTavilyKey, setSavingTavilyKey] = React.useState(false);
const [proxyInput, setProxyInput] = React.useState("");
const [savingProxy, setSavingProxy] = React.useState(false);
const [globalProxyInput, setGlobalProxyInput] = React.useState("");
const [savingGlobalProxy, setSavingGlobalProxy] = React.useState(false);
const [testing, setTesting] = React.useState(false);
const [loaded, setLoaded] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [savingKey, setSavingKey] = React.useState(false);
const [pyInterp, setPyInterp] = React.useState("");
const [workers, setWorkers] = React.useState("3");
const [savingWorkers, setSavingWorkers] = React.useState(false);
// 操作约束注入范围(默认都开)。
const [injectPlanner, setInjectPlanner] = React.useState(true);
const [injectWorker, setInjectWorker] = React.useState(true);
// 实验功能:noa 上下文压缩(默认关)。
const [noaCompaction, setNoaCompaction] = React.useState(false);
// 纯前端偏好:不走 /api/settings,直接读写 localStorage。
const sendMode = useChatSendMode();
const sendModeOptions = useChatSendModeOptions();
const apply = React.useCallback((s: Settings) => {
setTrafficCapture(!!s.traffic_capture);
setAgentTrafficBinding(!!s.agent_traffic_binding);
setWebSearch(!!s.web_search_enabled);
setBackend(s.web_search_backend || "ddgs");
setBraveKeySet(!!s.brave_key_set);
setTavilyKeySet(!!s.tavily_key_set);
setProxyInput(s.web_search_proxy ?? "");
setGlobalProxyInput(s.global_proxy ?? "");
setPyInterp(s.python_interpreter ?? "");
setWorkers(String(s.workers ?? 3));
setInjectPlanner(s.constraints_inject_planner !== false);
setInjectWorker(s.constraints_inject_worker !== false);
setNoaCompaction(!!s.noa_compaction);
}, []);
const saveWorkers = () => {
const n = Number(workers);
if (!Number.isInteger(n) || n <= 0) {
toast.error(t("toast.workersInvalid"));
return;
}
setSavingWorkers(true);
api
.setSettings({ workers: n })
.then((s) => {
apply(s);
toast.success(t("toast.workersSaved"));
})
.catch((e) => toast.error(t("toast.saveFailed", { error: (e as Error).message })))
.finally(() => setSavingWorkers(false));
};
const savePython = () => {
setSaving(true);
api
.setSettings({ python_interpreter: pyInterp.trim() })
.then((s) => {
apply(s);
toast.success(t("toast.pythonSaved"));
})
.catch((e) => toast.error(t("toast.saveFailed", { error: (e as Error).message })))
.finally(() => setSaving(false));
};
const detectPython = () => {
setSaving(true);
api
.detectPython()
.then((r) => setPyInterp(r.python_interpreter))
.catch(() => undefined)
.finally(() => setSaving(false));
};
React.useEffect(() => {
api
.settings()
.then(apply)
.catch(() => undefined)
.finally(() => setLoaded(true));
}, [apply]);
const toggleTraffic = (v: boolean) => {
setTrafficCapture(v); // optimistic
setSaving(true);
api
.setSettings({ traffic_capture: v })
.then(apply)
.catch(() => setTrafficCapture(!v)) // revert on failure
.finally(() => setSaving(false));
};
const toggleInjectPlanner = (v: boolean) => {
setInjectPlanner(v); // optimistic
api
.setSettings({ constraints_inject_planner: v })
.then(apply)
.catch(() => setInjectPlanner(!v)); // revert on failure
};
const toggleAgentTrafficBinding = (v: boolean) => {
setAgentTrafficBinding(v);
setSaving(true);
api
.setSettings({ agent_traffic_binding: v })
.then((s) => {
apply(s);
toast.success(v ? t("toast.bindingOn") : t("toast.bindingOff"));
})
.catch((e) => {
setAgentTrafficBinding(!v);
toast.error(t("toast.saveFailed", { error: (e as Error).message }));
})
.finally(() => setSaving(false));
};
const toggleInjectWorker = (v: boolean) => {
setInjectWorker(v); // optimistic
api
.setSettings({ constraints_inject_worker: v })
.then(apply)
.catch(() => setInjectWorker(!v)); // revert on failure
};
const toggleNoaCompaction = (v: boolean) => {
setNoaCompaction(v); // optimistic
api
.setSettings({ noa_compaction: v })
.then((s) => {
apply(s);
toast.success(v ? t("toast.noaOn") : t("toast.noaOff"));
})
.catch((e) => {
setNoaCompaction(!v); // revert on failure
toast.error(t("toast.saveFailed", { error: (e as Error).message }));
});
};
// Persist a web-search patch (enable and/or backend). Optimistic with refetch.
const saveWebSearch = (patch: Partial<Settings>) => {
setSaving(true);
api
.setSettings(patch)
.then((s) => {
apply(s);
toast.success(t("toast.webSearchSaved"));
})
.catch((e) => {
toast.error(t("toast.saveFailed", { error: (e as Error).message }));
api
.settings()
.then(apply)
.catch(() => undefined);
})
.finally(() => setSaving(false));
};
const saveBraveKey = () => {
setSavingKey(true);
api
.setSettings({ brave_search_api_key: braveKeyInput })
.then((s) => {
apply(s);
setBraveKeyInput("");
toast.success(t("toast.braveKeySaved"));
})
.catch((e) => toast.error(t("toast.saveFailed", { error: (e as Error).message })))
.finally(() => setSavingKey(false));
};
const saveTavilyKey = () => {
setSavingTavilyKey(true);
api
.setSettings({ tavily_search_api_key: tavilyKeyInput })
.then((s) => {
apply(s);
setTavilyKeyInput("");
toast.success(t("toast.tavilyKeySaved"));
})
.catch((e) => toast.error(t("toast.saveFailed", { error: (e as Error).message })))
.finally(() => setSavingTavilyKey(false));
};
const saveProxy = () => {
setSavingProxy(true);
api
.setSettings({ web_search_proxy: proxyInput.trim() })
.then((s) => {
apply(s);
toast.success(proxyInput.trim() ? t("toast.proxySaved") : t("toast.proxyCleared"));
})
.catch((e) => toast.error(t("toast.saveFailed", { error: (e as Error).message })))
.finally(() => setSavingProxy(false));
};
const saveGlobalProxy = () => {
setSavingGlobalProxy(true);
api
.setSettings({ global_proxy: globalProxyInput.trim() })
.then((s) => {
apply(s);
toast.success(globalProxyInput.trim() ? t("toast.globalProxySaved") : t("toast.globalProxyCleared"));
})
.catch((e) => toast.error(t("toast.saveFailed", { error: (e as Error).message })))
.finally(() => setSavingGlobalProxy(false));
};
// Run a real "test" search ("test") against the CURRENT form values (backend +
// proxy + entered key), falling back to saved values server-side. Toasts result.
const runTest = () => {
setTesting(true);
api
.testWebSearch({
web_search_backend: backend,
web_search_proxy: proxyInput.trim(),
brave_search_api_key: braveKeyInput,
tavily_search_api_key: tavilyKeyInput,
})
.then((r) => {
if (r.ok) toast.success(t("toast.testSuccess", { backend: r.backend ?? "", count: r.count ?? 0 }));
else toast.error(t("toast.testFailed", { error: r.error || t("toast.testUnknownError") }));
})
.catch((e) => toast.error(t("toast.testFailed", { error: (e as Error).message })))
.finally(() => setTesting(false));
};
// brave-free selected but no key stored and none being entered → tool stays off.
const braveNeedsKey = webSearch && backend === "brave-free" && !braveKeySet;
return (
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div>
<h1 className="text-xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground text-sm">{t("subtitle")}</p>
</div>
{/* 多列而非 grid:网络搜索卡片比其余高数倍,且高度随所选后端变化(brave/tavily
的 key 输入是条件渲染)。grid 会按最高的一张撑满整行、在旁边留下大片空白,
多列则自动按内容高度平衡填充。卡片间距靠 mb 而非 gap——多列布局下
column-gap 只管列间距,行间距要由子元素自己给。 */}
<div className="columns-1 gap-4 md:gap-6 lg:columns-2">
<UpdateCard />
<Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<RadioTowerIcon className="size-4" />
{t("trafficCapture.title")}
</CardTitle>
<CardDescription>{t.rich("trafficCapture.desc", richTags)}</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between gap-4">
<Label htmlFor="traffic-capture" className="text-sm font-normal text-muted-foreground">
{trafficCapture ? t("trafficCapture.on") : t("trafficCapture.off")}
</Label>
<Switch
id="traffic-capture"
checked={trafficCapture}
disabled={!loaded || saving}
onCheckedChange={toggleTraffic}
/>
</CardContent>
</Card>
<Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<RadioTowerIcon className="size-4" />
{t("agentBinding.title")}
</CardTitle>
<CardDescription id="agent-traffic-binding-description">
{t.rich("agentBinding.desc", richTags)}
</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between gap-4">
<Label htmlFor="agent-traffic-binding" className="text-sm font-normal text-muted-foreground">
{agentTrafficBinding ? t("agentBinding.on") : t("agentBinding.off")}
</Label>
<Switch
id="agent-traffic-binding"
aria-describedby="agent-traffic-binding-description"
checked={agentTrafficBinding}
disabled={!loaded || saving}
onCheckedChange={toggleAgentTrafficBinding}
/>
</CardContent>
</Card>
<Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<RadioTowerIcon className="size-4" />
{t("globalProxy.title")}
</CardTitle>
<CardDescription>{t.rich("globalProxy.desc", richTags)}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<Label htmlFor="global-proxy" className="text-sm font-normal text-muted-foreground">
{t("globalProxy.addrLabel")}
</Label>
<div className="flex items-center gap-2">
<Input
id="global-proxy"
autoComplete="off"
placeholder={t("globalProxy.placeholder")}
value={globalProxyInput}
disabled={!loaded || savingGlobalProxy}
onChange={(e) => setGlobalProxyInput(e.target.value)}
/>
<Button type="button" onClick={saveGlobalProxy} disabled={!loaded || savingGlobalProxy}>
{t("save")}
</Button>
</div>
<p className="text-muted-foreground text-xs">
{globalProxyInput.trim() ? t("globalProxy.configured") : t("globalProxy.notConfigured")}
</p>
</CardContent>
</Card>
<Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ShieldAlertIcon className="size-4" />
{t("constraints.title")}
</CardTitle>
<CardDescription>{t.rich("constraints.desc", richTags)}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<Label htmlFor="inject-planner" className="text-sm font-normal text-muted-foreground">
{t("constraints.injectPlanner")}
{injectPlanner ? t("onSuffix") : t("offSuffix")}
</Label>
<Switch
id="inject-planner"
checked={injectPlanner}
disabled={!loaded}
onCheckedChange={toggleInjectPlanner}
/>
</div>
<div className="flex items-center justify-between gap-4">
<Label htmlFor="inject-worker" className="text-sm font-normal text-muted-foreground">
{t("constraints.injectWorker")}
{injectWorker ? t("onSuffix") : t("offSuffix")}
</Label>
<Switch
id="inject-worker"
checked={injectWorker}
disabled={!loaded}
onCheckedChange={toggleInjectWorker}
/>
</div>
</CardContent>
</Card>
<Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FlaskConicalIcon className="size-4" />
{t("experimental.title")}
</CardTitle>
<CardDescription>{t.rich("experimental.desc", richTags)}</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between gap-4">
<Label htmlFor="noa-compaction" className="text-sm font-normal text-muted-foreground">
{t("experimental.noaLabel")}
{noaCompaction ? t("onSuffix") : t("offSuffix")}
</Label>
<Switch
id="noa-compaction"
checked={noaCompaction}
disabled={!loaded}
onCheckedChange={toggleNoaCompaction}
/>
</CardContent>
</Card>
<Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<SearchIcon className="size-4" />
{t("webSearch.title")}
</CardTitle>
<CardDescription>{t.rich("webSearch.desc", richTags)}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<Label htmlFor="web-search" className="text-sm font-normal text-muted-foreground">
{webSearch ? t("webSearch.on") : t("webSearch.off")}
</Label>
<Switch
id="web-search"
checked={webSearch}
disabled={!loaded || saving}
onCheckedChange={(v) => {
setWebSearch(v); // optimistic
saveWebSearch({ web_search_enabled: v });
}}
/>
</div>
{webSearch && (
<div className="flex items-center justify-between gap-4">
<Label className="text-sm font-normal text-muted-foreground">{t("webSearch.sourceLabel")}</Label>
<Select
value={backend}
disabled={!loaded || saving}
onValueChange={(v) => {
setBackend(v); // optimistic
saveWebSearch({ web_search_backend: v });
}}
>
<SelectTrigger className="w-48 shrink-0">
<SelectValue placeholder={t("webSearch.sourcePlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ddgs">{t("webSearch.sourceDdgs")}</SelectItem>
<SelectItem value="brave-free">{t("webSearch.sourceBrave")}</SelectItem>
<SelectItem value="tavily">{t("webSearch.sourceTavily")}</SelectItem>
<SelectItem value="deepseek">{t("webSearch.sourceDeepseek")}</SelectItem>
</SelectContent>
</Select>
</div>
)}
{webSearch && backend === "deepseek" && (
<div className="border-border/60 bg-muted/30 flex flex-col gap-2 rounded-md border p-3">
<p className="text-sm font-medium">{t("webSearch.deepseekTitle")}</p>
<p className="text-muted-foreground text-xs leading-relaxed">
{t.rich("webSearch.deepseekDesc1", richTags)}
</p>
<p className="text-muted-foreground text-xs leading-relaxed">
{t.rich("webSearch.deepseekDesc2", richTags)}
</p>
<p className="text-muted-foreground text-xs leading-relaxed">{t("webSearch.deepseekDesc3")}</p>
</div>
)}
{webSearch && backend === "brave-free" && (
<div className="flex flex-col gap-2">
<Label htmlFor="brave-key" className="text-sm font-normal text-muted-foreground">
{t("webSearch.braveKeyLabel")}
{braveKeySet && <span className="ml-2 text-xs text-emerald-500">{t("configured")}</span>}
</Label>
<div className="flex items-center gap-2">
<Input
id="brave-key"
type="password"
autoComplete="off"
placeholder={
braveKeySet ? t("webSearch.braveKeyPlaceholderSet") : t("webSearch.braveKeyPlaceholderUnset")
}
value={braveKeyInput}
disabled={!loaded || savingKey}
onChange={(e) => setBraveKeyInput(e.target.value)}
/>
<Button
type="button"
onClick={saveBraveKey}
disabled={!loaded || savingKey || braveKeyInput.trim() === ""}
>
{t("save")}
</Button>
</div>
{braveNeedsKey && <p className="text-xs text-amber-500">{t("webSearch.braveNeedsKey")}</p>}
<p className="text-muted-foreground text-xs">{t("webSearch.braveQuota")}</p>
</div>
)}
{webSearch && backend === "tavily" && (
<div className="flex flex-col gap-2">
<Label htmlFor="tavily-key" className="text-sm font-normal text-muted-foreground">
{t("webSearch.tavilyKeyLabel")}
{tavilyKeySet && <span className="ml-2 text-xs text-emerald-500">{t("configured")}</span>}
</Label>
<div className="flex items-center gap-2">
<Input
id="tavily-key"
type="password"
autoComplete="off"
placeholder={
tavilyKeySet ? t("webSearch.tavilyKeyPlaceholderSet") : t("webSearch.tavilyKeyPlaceholderUnset")
}
value={tavilyKeyInput}
disabled={!loaded || savingTavilyKey}
onChange={(e) => setTavilyKeyInput(e.target.value)}
/>
<Button
type="button"
onClick={saveTavilyKey}
disabled={!loaded || savingTavilyKey || tavilyKeyInput.trim() === ""}
>
{t("save")}
</Button>
</div>
{webSearch && backend === "tavily" && !tavilyKeySet && (
<p className="text-xs text-amber-500">{t("webSearch.tavilyNeedsKey")}</p>
)}
<p className="text-muted-foreground text-xs">{t("webSearch.tavilyGetKey")}</p>
</div>
)}
{webSearch && (
<div className="flex flex-col gap-2">
<Label htmlFor="ws-proxy" className="text-sm font-normal text-muted-foreground">
{t("webSearch.proxyLabel")}
</Label>
<div className="flex items-center gap-2">
<Input
id="ws-proxy"
autoComplete="off"
placeholder={t("webSearch.proxyPlaceholder")}
value={proxyInput}
disabled={!loaded || savingProxy}
onChange={(e) => setProxyInput(e.target.value)}
/>
<Button type="button" onClick={saveProxy} disabled={!loaded || savingProxy}>
{t("save")}
</Button>
</div>
<p className="text-muted-foreground text-xs">{t("webSearch.proxyHint")}</p>
</div>
)}
{webSearch && (
<div className="flex items-center justify-between gap-4 border-t pt-4">
<p className="text-muted-foreground text-xs">{t("webSearch.testHint")}</p>
<Button
type="button"
variant="outline"
onClick={runTest}
disabled={!loaded || testing}
className="shrink-0"
>
{testing ? t("webSearch.testRunning") : t("webSearch.testButton")}
</Button>
</div>
)}
</CardContent>
</Card>
<Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<RadioTowerIcon className="size-4" />
{t("python.title")}
</CardTitle>
<CardDescription>{t.rich("python.desc", richTags)}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Input
className="font-mono text-sm"
placeholder={t("python.placeholder")}
value={pyInterp}
disabled={!loaded || saving}
onChange={(e) => setPyInterp(e.target.value)}
/>
<Button variant="outline" onClick={detectPython} disabled={!loaded || saving}>
{t("python.detect")}
</Button>
<Button onClick={savePython} disabled={!loaded || saving}>
{t("save")}
</Button>
</div>
</CardContent>
</Card>
<Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CpuIcon className="size-4" />
{t("workers.title")}
</CardTitle>
<CardDescription>{t.rich("workers.desc", richTags)}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
className="w-32 font-mono text-sm"
placeholder="3"
value={workers}
disabled={!loaded || savingWorkers}
onChange={(e) => setWorkers(e.target.value)}
/>
<Button onClick={saveWorkers} disabled={!loaded || savingWorkers}>
{t("save")}
</Button>
</div>
</CardContent>
</Card>
<Card className="mb-4 break-inside-avoid md:mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<KeyboardIcon className="size-4" />
{t("sendKey.title")}
</CardTitle>
<CardDescription>{t.rich("sendKey.desc", richTags)}</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between gap-4">
<Label htmlFor="chat-send-mode" className="text-sm font-normal text-muted-foreground">
{t("sendKey.label")}
</Label>
<Select value={sendMode} onValueChange={(v) => setChatSendMode(v as ChatSendMode)}>
<SelectTrigger id="chat-send-mode" className="w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sendModeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+956
View File
@@ -0,0 +1,956 @@
"use client";
import * as React from "react";
import { PlayIcon, PlusIcon, RotateCcwIcon, SaveIcon, SearchIcon, Trash2Icon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import type { Agent, Tool } from "@/lib/types";
// Traffic tools are host tools gated by the global 流量捕获 switch: bindable, but
// only usable when capture is on. Keep in sync with traffic.SeedToolMetas.
const TRAFFIC_TOOL_KEYS = new Set(["traffic_search", "traffic_get"]);
// paramRows flattens schema.properties into an ordered row list for editing.
// name/type/required are read-only (welded to the Go handler); description/default
// are editable. Non-scalar params (array/object) can't carry an editable default.
// parentKey is set for rows that live inside an array param's items.properties.
type ParamRow = {
name: string;
type: string;
required: boolean;
description: string;
defaultStr: string; // "" = no default declared
scalar: boolean;
parentKey?: string; // set when this is a sub-field of an array param's items
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function toRows(schema: Record<string, any>): ParamRow[] {
const props = (schema?.properties ?? {}) as Record<string, Record<string, unknown>>;
const required = new Set<string>((schema?.required as string[]) ?? []);
const rows: ParamRow[] = [];
for (const [name, p] of Object.entries(props)) {
const type = String(p?.type ?? "");
const hasDefault = p != null && "default" in p && (p as Record<string, unknown>).default != null;
rows.push({
name,
type,
required: required.has(name),
description: String(p?.description ?? ""),
defaultStr: hasDefault ? String((p as Record<string, unknown>).default) : "",
scalar: ["string", "integer", "number", "boolean"].includes(type),
});
// Expand items.properties for array params so sub-fields are editable.
if (type === "array") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const itemProps = (p as any)?.items?.properties as Record<string, Record<string, unknown>> | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const itemRequired = new Set<string>(((p as any)?.items?.required as string[]) ?? []);
if (itemProps) {
for (const [subName, subP] of Object.entries(itemProps)) {
const subType = String((subP as Record<string, unknown>)?.type ?? "");
const subHasDefault = subP != null && "default" in subP && subP.default != null;
rows.push({
name: subName,
type: subType,
required: itemRequired.has(subName),
description: String(subP?.description ?? ""),
defaultStr: subHasDefault ? String(subP.default) : "",
scalar: ["string", "integer", "number", "boolean"].includes(subType),
parentKey: name,
});
}
}
}
}
return rows;
}
// coerceDefault turns the edited default string back into a typed JSON value, or
// undefined to drop the "default" key entirely.
function coerceDefault(type: string, raw: string): unknown {
const s = raw.trim();
if (s === "") return undefined;
if (type === "integer" || type === "number") {
const n = Number(s);
return Number.isNaN(n) ? undefined : n;
}
if (type === "boolean") return s === "true";
return raw;
}
// applyRows writes edited rows back into a deep-copied schema (structure untouched).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function applyRows(schema: Record<string, any>, rows: ParamRow[]): Record<string, any> {
const next = structuredClone(schema ?? {});
const props = (next.properties ?? {}) as Record<string, Record<string, unknown>>;
for (const r of rows) {
if (r.parentKey) {
// Sub-field of an array param's items.properties
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const parent = props[r.parentKey] as any;
const subP = parent?.items?.properties?.[r.name] as Record<string, unknown> | undefined;
if (!subP) continue;
subP.description = r.description;
if (r.scalar) {
const dv = coerceDefault(r.type, r.defaultStr);
if (dv === undefined) delete subP.default;
else subP.default = dv;
}
} else {
const p = props[r.name];
if (!p) continue;
p.description = r.description;
if (r.scalar) {
const dv = coerceDefault(r.type, r.defaultStr);
if (dv === undefined) delete p.default;
else p.default = dv;
}
}
}
return next;
}
// ToolEditor is the full edit form for one tool, rendered inside the drawer.
function ToolEditor({
tool,
agents,
captureOn,
onSaved,
onClose,
}: {
tool: Tool;
agents: Agent[];
captureOn: boolean;
onSaved: () => void;
onClose: () => void;
}) {
const t = useTranslations("toolsPage");
// traffic tools can't be bound/enabled until the global 流量捕获 switch is on.
const trafficGated = TRAFFIC_TOOL_KEYS.has(tool.key) && !captureOn;
const [description, setDescription] = React.useState(tool.description);
const [bound, setBound] = React.useState<string[]>(tool.agents);
const [enabled, setEnabled] = React.useState(tool.enabled);
const [rows, setRows] = React.useState<ParamRow[]>(() => toRows(tool.schema));
const [saving, setSaving] = React.useState(false);
const setRow = (i: number, patch: Partial<ParamRow>) =>
setRows((rs) => rs.map((r, j) => (j === i ? { ...r, ...patch } : r)));
const toggleAgent = (k: string) => setBound((b) => (b.includes(k) ? b.filter((x) => x !== k) : [...b, k]));
async function save() {
setSaving(true);
try {
await api.saveTool(tool.key, {
description,
schema: applyRows(tool.schema, rows),
agents: bound,
enabled,
});
toast.success(t("savedTool", { key: tool.key }));
onSaved();
onClose();
} catch (e) {
toast.error(t("saveFailed", { msg: (e as Error).message }));
} finally {
setSaving(false);
}
}
async function reset() {
try {
await api.resetTool(tool.key);
toast.success(t("resetDone", { key: tool.key }));
onSaved();
onClose();
} catch (e) {
toast.error(t("resetFailed", { msg: (e as Error).message }));
}
}
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4">
{trafficGated && (
<div className="border-amber-500/40 bg-amber-500/10 text-muted-foreground rounded-md border px-3 py-2 text-xs">
{t.rich("trafficGatedNote", { b: (c) => <b>{c}</b> })}
</div>
)}
{/* binding + switch */}
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">{t("bindAgentLabel")}</Label>
<div className="flex flex-wrap gap-3">
{agents.map((ag) => (
<label key={ag.key} className="flex items-center gap-2 text-sm">
<Checkbox
checked={bound.includes(ag.key)}
disabled={trafficGated}
onCheckedChange={() => toggleAgent(ag.key)}
/>
{ag.name}
<span className="text-muted-foreground font-mono text-xs">{ag.key}</span>
</label>
))}
{agents.length === 0 && <span className="text-muted-foreground text-xs">{t("noAgents")}</span>}
</div>
</div>
<div className="flex items-center gap-2">
<Switch checked={enabled} onCheckedChange={setEnabled} id={`en-${tool.key}`} />
<Label htmlFor={`en-${tool.key}`} className="text-sm">
{t("enabled")}
</Label>
</div>
</div>
{/* description */}
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">{t("descLabel")}</Label>
<Textarea
className="font-mono text-xs"
rows={6}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
{/* params */}
<div className="grid gap-2">
<Label className="text-muted-foreground text-xs">{t("paramsLabel")}</Label>
{rows.length === 0 && <span className="text-muted-foreground text-xs">{t("noParams")}</span>}
{rows.map((r, i) => (
<div
key={(r.parentKey ?? "") + "." + r.name}
className={
r.parentKey ? "border-l-2 border-muted ml-3 pl-3 grid gap-2 py-2" : "grid gap-2 rounded-md border p-3"
}
>
<div className="flex flex-wrap items-center gap-2">
{r.parentKey && <span className="text-muted-foreground font-mono text-[10px]">↳</span>}
<span className="font-mono text-sm">{r.name}</span>
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
{r.type || "?"}
</Badge>
{r.required && (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
{t("required")}
</Badge>
)}
{r.parentKey && <span className="text-muted-foreground text-[10px]">{t("itemsSubfield")}</span>}
</div>
<div className="grid gap-2">
<div className="grid gap-1">
<Label className="text-muted-foreground text-[11px]">{t("rowDesc")}</Label>
<Input
className="text-xs"
value={r.description}
onChange={(e) => setRow(i, { description: e.target.value })}
/>
</div>
<div className="grid gap-1">
<Label className="text-muted-foreground text-[11px]">{t("rowDefault")}</Label>
<Input
className="text-xs"
placeholder={r.scalar ? t("defaultEmptyHint") : t("scalarOnly")}
disabled={!r.scalar}
value={r.defaultStr}
onChange={(e) => setRow(i, { defaultStr: e.target.value })}
/>
</div>
</div>
</div>
))}
</div>
</div>
<Separator className="mt-4" />
<div className="flex flex-wrap gap-2 p-4">
<Button size="sm" onClick={save} disabled={saving}>
<SaveIcon /> {t("save")}
</Button>
<Button size="sm" variant="outline" onClick={reset}>
<RotateCcwIcon /> {t("resetDefault")}
</Button>
</div>
</div>
);
}
// ToolGridCard is one clickable tile in the catalog grid.
function ToolGridCard({ tool, onClick }: { tool: Tool; onClick: () => void }) {
const t = useTranslations("toolsPage");
return (
<button
type="button"
onClick={onClick}
className="hover:border-primary/50 hover:bg-muted/40 focus-visible:ring-ring flex flex-col gap-2 rounded-lg border p-4 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm font-medium">{tool.key}</span>
{tool.system ? (
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
{t("badgeSystem")}
</Badge>
) : (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
{t("badgeCustom")}·{tool.kind}
</Badge>
)}
{tool.deferred && (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
deferred
</Badge>
)}
{!tool.enabled && (
<Badge variant="outline" className="text-destructive px-1.5 py-0 text-[10px]">
{t("badgeDisabled")}
</Badge>
)}
<Badge
variant="secondary"
className="ml-auto px-1.5 py-0 text-[10px] tabular-nums"
title={t("callsTitle", { count: tool.calls ?? 0 })}
>
{t("callsBadge", { count: tool.calls ?? 0 })}
</Badge>
</div>
<p className="text-muted-foreground line-clamp-1 h-4 text-xs">{tool.description || t("noDescription")}</p>
<div className="mt-auto flex flex-wrap gap-1 pt-1">
{tool.agents.length === 0 && <span className="text-muted-foreground text-[10px]">{t("noBoundAgents")}</span>}
{tool.agents.map((a) => (
<Badge key={a} variant="outline" className="px-1.5 py-0 text-[10px]">
{a}
</Badge>
))}
</div>
</button>
);
}
export default function ToolsPage() {
const t = useTranslations("toolsPage");
const [tools, setTools] = React.useState<Tool[]>([]);
const [agents, setAgents] = React.useState<Agent[]>([]);
const [captureOn, setCaptureOn] = React.useState(false);
const [selectedKey, setSelectedKey] = React.useState<string | null>(null);
const [customEdit, setCustomEdit] = React.useState<Tool | "new" | null>(null);
const reload = React.useCallback(() => {
api
.tools()
.then(setTools)
.catch(() => setTools([]));
}, []);
React.useEffect(() => {
reload();
api
.agents()
.then(setAgents)
.catch(() => {
/* 조회 실패는 무시하고 기본 상태를 유지한다 */
});
api
.settings()
.then((s) => setCaptureOn(!!s.traffic_capture))
.catch(() => {
/* 조회 실패는 무시하고 기본 상태를 유지한다 */
});
}, [reload]);
const [query, setQuery] = React.useState("");
const selected = tools.find((t) => t.key === selectedKey) ?? null;
const matchTool = React.useCallback(
(t: Tool) => {
const q = query.trim().toLowerCase();
if (!q) return true;
return (
t.key.toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q) ||
t.agents.some((a) => a.toLowerCase().includes(q))
);
},
[query],
);
const systemTools = tools.filter((t) => t.system && matchTool(t));
const customTools = tools.filter((t) => !t.system && matchTool(t));
const allSystemCount = tools.filter((t) => t.system).length;
const allCustomCount = tools.filter((t) => !t.system).length;
// clicking a built-in tool opens the binding drawer; a custom tool opens its full editor.
const openTool = (tool: Tool) => (tool.system ? setSelectedKey(tool.key) : setCustomEdit(tool));
return (
<div className="flex flex-1 flex-col gap-4 md:gap-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground text-sm">{t("subtitle")}</p>
</div>
<div className="relative w-64">
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2" />
<Input
className="h-8 pl-8 text-sm"
placeholder={t("searchPlaceholder")}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
</div>
<Tabs defaultValue="system">
<TabsList>
<TabsTrigger value="system">{t("tabSystem")}</TabsTrigger>
<TabsTrigger value="custom">{t("tabCustom")}</TabsTrigger>
</TabsList>
<TabsContent value="system">
<Card>
<CardHeader>
<CardTitle>{t("tabSystem")}</CardTitle>
<CardDescription>
{query.trim()
? t("systemMatched", { shown: systemTools.length, total: allSystemCount })
: t("systemTotal", { total: allSystemCount })}
</CardDescription>
</CardHeader>
<CardContent>
{systemTools.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
{query.trim() ? t("systemNoMatch") : t("systemEmpty")}
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{systemTools.map((tool) => (
<ToolGridCard key={tool.key} tool={tool} onClick={() => openTool(tool)} />
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="custom">
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>{t("tabCustom")}</CardTitle>
<CardDescription>
{query.trim()
? t("customMatched", { shown: customTools.length, total: allCustomCount })
: t("customTotal", { total: allCustomCount })}
</CardDescription>
</div>
<Button size="sm" onClick={() => setCustomEdit("new")}>
<PlusIcon /> {t("newCustom")}
</Button>
</div>
</CardHeader>
<CardContent>
{customTools.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
{query.trim() ? t("customNoMatch") : t("customEmpty")}
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{customTools.map((tool) => (
<ToolGridCard key={tool.key} tool={tool} onClick={() => openTool(tool)} />
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
<Sheet open={!!selected} onOpenChange={(o) => !o && setSelectedKey(null)}>
<SheetContent side="right" className="gap-0 p-0 data-[side=right]:w-[30vw] data-[side=right]:sm:max-w-[30vw]">
{selected && (
<>
<SheetHeader className="px-4">
<SheetTitle className="font-mono">{selected.key}</SheetTitle>
<SheetDescription>{t("editSheetDesc")}</SheetDescription>
</SheetHeader>
<ToolEditor
key={selected.key}
tool={selected}
agents={agents}
captureOn={captureOn}
onSaved={reload}
onClose={() => setSelectedKey(null)}
/>
</>
)}
</SheetContent>
</Sheet>
<CustomToolDialog
edit={customEdit}
agents={agents}
onClose={() => setCustomEdit(null)}
onSaved={() => {
setCustomEdit(null);
reload();
}}
/>
</div>
);
}
// ---- 自定义工具编辑器 ----
type ExecState = {
command: string;
code: string;
method: string;
url: string;
headers: string;
body: string;
timeout_ms: string;
proxy: string;
use_recording_proxy: boolean;
};
function CustomToolDialog({
edit,
agents,
onClose,
onSaved,
}: {
edit: Tool | "new" | null;
agents: Agent[];
onClose: () => void;
onSaved: () => void;
}) {
const t = useTranslations("toolsPage");
const isNew = edit === "new";
const tool = edit && edit !== "new" ? edit : null;
const [key, setKey] = React.useState("");
const [description, setDescription] = React.useState("");
const [kind, setKind] = React.useState<"shell" | "command" | "script" | "http">("shell");
const [ex, setEx] = React.useState<ExecState>({
command: "",
code: "",
method: "GET",
url: "",
headers: "",
body: "",
timeout_ms: "",
proxy: "",
use_recording_proxy: false,
});
const [schemaText, setSchemaText] = React.useState("");
const [bound, setBound] = React.useState<string[]>([]);
const [deferred, setDeferred] = React.useState(false);
const [enabled, setEnabled] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [paramsText, setParamsText] = React.useState("");
const [testing, setTesting] = React.useState(false);
const [testResult, setTestResult] = React.useState<{ output: string; is_error: boolean } | null>(null);
// (re)load form state when opening.
React.useEffect(() => {
if (!edit) return;
setParamsText("");
setTestResult(null);
if (edit === "new") {
setKey("");
setDescription("");
setKind("shell");
setEx({
command: "",
code: "",
method: "GET",
url: "",
headers: "",
body: "",
timeout_ms: "",
proxy: "",
use_recording_proxy: false,
});
setSchemaText("");
setBound([]);
setDeferred(false);
setEnabled(true);
return;
}
const t = edit;
const e = (t.exec ?? {}) as Record<string, unknown>;
setKey(t.key);
setDescription(t.description);
setKind((t.kind as "shell" | "command" | "script" | "http") ?? "shell");
setEx({
command: String(e.command ?? ""),
code: String(e.code ?? ""),
method: String(e.method ?? "GET"),
url: String(e.url ?? ""),
headers: e.headers ? JSON.stringify(e.headers, null, 2) : "",
body: String(e.body ?? ""),
timeout_ms: e.timeout_ms ? String(e.timeout_ms) : "",
proxy: String(e.proxy ?? ""),
use_recording_proxy: !!e.use_recording_proxy,
});
setSchemaText(t.schema && Object.keys(t.schema).length ? JSON.stringify(t.schema, null, 2) : "");
setBound(t.agents ?? []);
setDeferred(!!t.deferred);
setEnabled(t.enabled);
}, [edit]);
const toggleAgent = (k: string) => setBound((b) => (b.includes(k) ? b.filter((x) => x !== k) : [...b, k]));
function buildExec(): Record<string, unknown> {
if (kind === "shell") return {};
const t = ex.timeout_ms ? Number(ex.timeout_ms) : undefined;
if (kind === "command") return { command: ex.command, timeout_ms: t };
if (kind === "script") return { code: ex.code, timeout_ms: t };
let headers: Record<string, string> = {};
if (ex.headers.trim()) {
try {
headers = JSON.parse(ex.headers);
} catch {
/* validated on save */
}
}
return {
method: ex.method,
url: ex.url,
headers,
body: ex.body,
timeout_ms: t,
proxy: ex.proxy,
use_recording_proxy: ex.use_recording_proxy,
};
}
async function save() {
if (isNew && !/^[a-z][a-z0-9_]*$/.test(key.trim())) {
toast.error(t("keyError"));
return;
}
let schema: Record<string, unknown> = {};
if (schemaText.trim()) {
try {
schema = JSON.parse(schemaText);
} catch {
toast.error(t("schemaError"));
return;
}
}
if (kind === "http" && ex.headers.trim()) {
try {
JSON.parse(ex.headers);
} catch {
toast.error(t("headersError"));
return;
}
}
if (kind === "http") {
const props = (schema as { properties?: Record<string, unknown> }).properties;
if (!props || Object.keys(props).length === 0) {
toast.error(t("httpSchemaRequired"));
return;
}
}
setSaving(true);
const payload = {
description,
schema: kind === "shell" ? {} : schema,
agents: bound,
enabled,
kind,
exec: buildExec(),
deferred: kind === "shell" ? false : deferred,
};
try {
if (isNew) await api.createCustomTool({ key: key.trim(), ...payload });
else await api.updateCustomTool(tool!.key, payload);
toast.success(isNew ? t("createdCustom") : t("saved"));
onSaved();
} catch (e) {
toast.error(t("saveFailed", { msg: (e as Error).message }));
} finally {
setSaving(false);
}
}
async function del() {
if (!tool) return;
try {
await api.deleteCustomTool(tool.key);
toast.success(t("deleted"));
onSaved();
} catch (e) {
toast.error(t("deleteFailed", { msg: (e as Error).message }));
}
}
// runTest dry-runs the CURRENT form (unsaved) with the sample params, so a
// template/script/request can be debugged before saving.
async function runTest() {
let params: Record<string, unknown> = {};
if (paramsText.trim()) {
try {
params = JSON.parse(paramsText);
} catch {
toast.error(t("testParamsError"));
return;
}
}
if (kind === "http" && ex.headers.trim()) {
try {
JSON.parse(ex.headers);
} catch {
toast.error(t("headersError"));
return;
}
}
setTesting(true);
setTestResult(null);
try {
const r = await api.testCustomTool({ kind, exec: buildExec(), params });
setTestResult(r);
} catch (e) {
setTestResult({ output: (e as Error).message, is_error: true });
} finally {
setTesting(false);
}
}
let sheetTitle = t("newCustom");
if (!isNew && tool) sheetTitle = t("editCustom", { key: tool.key });
return (
<Sheet open={!!edit} onOpenChange={(o) => !o && onClose()}>
<SheetContent
side="right"
className="flex flex-col gap-0 p-0 data-[side=right]:w-[45vw] data-[side=right]:sm:max-w-[45vw] data-[side=right]:min-w-[480px]"
>
<SheetHeader className="px-4">
<SheetTitle>{sheetTitle}</SheetTitle>
<SheetDescription>{t("customSheetDesc")}</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4">
<div className="grid gap-1.5">
<Label className="text-xs">Key</Label>
<Input
className="font-mono"
placeholder={t("keyPlaceholder")}
value={key}
disabled={!isNew}
onChange={(e) => setKey(e.target.value)}
/>
</div>
<div className="grid gap-1.5">
<Label className="text-xs">{t("descLabelShort")}</Label>
<Textarea rows={2} value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<div className="grid gap-1.5">
<Label className="text-xs">{t("kindLabel")}</Label>
<Select value={kind} onValueChange={(v) => setKind(v as "shell" | "command" | "script" | "http")}>
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="shell">{t("kindShell")}</SelectItem>
<SelectItem value="command">{t("kindCommand")}</SelectItem>
<SelectItem value="script">{t("kindScript")}</SelectItem>
<SelectItem value="http">{t("kindHttp")}</SelectItem>
</SelectContent>
</Select>
{kind === "shell" && <p className="text-muted-foreground text-xs">{t("shellHint")}</p>}
</div>
{kind === "command" && (
<div className="grid gap-1.5">
<Label className="text-xs">{t("cmdTemplateLabel")}</Label>
<Textarea
className="font-mono text-xs"
rows={2}
value={ex.command}
onChange={(e) => setEx({ ...ex, command: e.target.value })}
/>
</div>
)}
{kind === "script" && (
<div className="grid gap-1.5">
<Label className="text-xs">{t("scriptLabel")}</Label>
<Textarea
className="font-mono text-xs"
rows={10}
value={ex.code}
placeholder={"import json,sys\nargs=json.load(sys.stdin)\nprint(...)"}
onChange={(e) => setEx({ ...ex, code: e.target.value })}
/>
</div>
)}
{kind === "http" && (
<div className="grid gap-2">
<div className="flex gap-2">
<div className="grid gap-1.5">
<Label className="text-xs">Method</Label>
<Input
className="w-24"
value={ex.method}
onChange={(e) => setEx({ ...ex, method: e.target.value })}
/>
</div>
<div className="grid flex-1 gap-1.5">
<Label className="text-xs">{t("httpUrlLabel")}</Label>
<Input
className="font-mono text-xs"
value={ex.url}
onChange={(e) => setEx({ ...ex, url: e.target.value })}
/>
</div>
</div>
<div className="grid gap-1.5">
<Label className="text-xs">{t("httpHeadersLabel")}</Label>
<Textarea
className="font-mono text-xs"
rows={2}
value={ex.headers}
placeholder={'{"Authorization": "Bearer {token}"}'}
onChange={(e) => setEx({ ...ex, headers: e.target.value })}
/>
</div>
<div className="grid gap-1.5">
<Label className="text-xs">{t("httpBodyLabel")}</Label>
<Textarea
className="font-mono text-xs"
rows={2}
value={ex.body}
onChange={(e) => setEx({ ...ex, body: e.target.value })}
/>
</div>
<div className="flex items-center gap-4">
<div className="grid gap-1.5">
<Label className="text-xs">{t("proxyLabel")}</Label>
<Input
className="font-mono text-xs w-56"
value={ex.proxy}
onChange={(e) => setEx({ ...ex, proxy: e.target.value })}
/>
</div>
<label className="mt-4 flex items-center gap-2 text-sm">
<Checkbox
checked={ex.use_recording_proxy}
onCheckedChange={(v) => setEx({ ...ex, use_recording_proxy: !!v })}
/>
{t("useRecordingProxy")}
</label>
</div>
</div>
)}
{kind !== "shell" && (
<div className="flex items-center gap-3">
<div className="grid gap-1.5">
<Label className="text-xs">{t("timeoutLabel")}</Label>
<Input
type="number"
className="w-32"
value={ex.timeout_ms}
onChange={(e) => setEx({ ...ex, timeout_ms: e.target.value })}
/>
</div>
</div>
)}
{kind !== "shell" && (
<div className="grid gap-1.5">
<Label className="text-xs">
{t("schemaLabel")}
{kind === "http" ? t("schemaHttpSuffix") : t("schemaDefaultSuffix")}
</Label>
<Textarea
className="font-mono text-xs"
rows={4}
value={schemaText}
placeholder={'{"type":"object","properties":{"target":{"type":"string"}},"required":["target"]}'}
onChange={(e) => setSchemaText(e.target.value)}
/>
</div>
)}
<div className="grid gap-1.5">
<Label className="text-muted-foreground text-xs">{t("bindAgentShort")}</Label>
<div className="flex flex-wrap gap-3">
{agents.map((a) => (
<label key={a.key} className="flex items-center gap-2 text-sm">
<Checkbox checked={bound.includes(a.key)} onCheckedChange={() => toggleAgent(a.key)} />
{a.name}
<span className="text-muted-foreground font-mono text-xs">{a.key}</span>
</label>
))}
</div>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 text-sm">
<Switch checked={enabled} onCheckedChange={setEnabled} /> {t("enabled")}
</label>
{kind !== "shell" && (
<label className="flex items-center gap-2 text-sm">
<Switch checked={deferred} onCheckedChange={setDeferred} /> {t("deferredLabel")}
</label>
)}
</div>
{kind !== "shell" && (
<div className="grid gap-1.5 rounded-md border p-3">
<Label className="text-xs font-medium">{t("testSectionLabel")}</Label>
<Textarea
className="font-mono text-xs"
rows={2}
value={paramsText}
placeholder={t("testParamsPlaceholder")}
onChange={(e) => setParamsText(e.target.value)}
/>
<div>
<Button size="sm" variant="outline" onClick={runTest} disabled={testing}>
<PlayIcon /> {testing ? t("testRunning") : t("testRun")}
</Button>
</div>
{testResult && (
<pre
className={
"max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-muted p-2 font-mono text-xs " +
(testResult.is_error ? "text-destructive" : "")
}
>
{testResult.output || t("noOutput")}
</pre>
)}
</div>
)}
</div>
<Separator />
<div className="flex items-center gap-2 p-4">
<Button size="sm" onClick={save} disabled={saving}>
<SaveIcon /> {isNew ? t("create") : t("save")}
</Button>
{!isNew && (
<Button size="sm" variant="outline" className="text-destructive" onClick={del}>
<Trash2Icon /> {t("delete")}
</Button>
)}
</div>
</SheetContent>
</Sheet>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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

After

Width:  |  Height:  |  Size: 480 KiB

+60
View File
@@ -0,0 +1,60 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
import { NextIntlClientProvider } from "next-intl";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { APP_CONFIG } from "@/config/app-config";
import { resolveLocale } from "@/i18n/config";
import { fontVars } from "@/lib/fonts/registry";
import { PREFERENCE_DEFAULTS } from "@/lib/preferences/preferences-config";
import { ThemeBootScript } from "@/scripts/theme-boot";
import { PreferencesStoreProvider } from "@/stores/preferences/preferences-provider";
import "./globals.css";
export const metadata: Metadata = {
title: APP_CONFIG.meta.title,
description: APP_CONFIG.meta.description,
};
export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
const { theme_mode, theme_preset, content_layout, navbar_style, sidebar_variant, sidebar_collapsible, font } =
PREFERENCE_DEFAULTS;
const locale = resolveLocale();
return (
<html
lang={locale}
data-theme-mode={theme_mode}
data-theme-preset={theme_preset}
data-content-layout={content_layout}
data-navbar-style={navbar_style}
data-sidebar-variant={sidebar_variant}
data-sidebar-collapsible={sidebar_collapsible}
data-font={font}
suppressHydrationWarning
>
<head>
{/* Applies theme and layout preferences on load to avoid flicker and unnecessary server rerenders. */}
<ThemeBootScript />
</head>
<body className={`${fontVars} min-h-screen antialiased`}>
<NextIntlClientProvider>
<TooltipProvider>
<PreferencesStoreProvider
themeMode={theme_mode}
themePreset={theme_preset}
contentLayout={content_layout}
navbarStyle={navbar_style}
font={font}
>
{children}
<Toaster />
</PreferencesStoreProvider>
</TooltipProvider>
</NextIntlClientProvider>
</body>
</html>
);
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
export default function NotFound() {
const t = useTranslations("notFound");
return (
<div className="flex h-dvh flex-col items-center justify-center space-y-2 text-center">
<h1 className="font-semibold text-2xl">{t("title")}</h1>
<p className="text-muted-foreground">{t("description")}</p>
<Link prefetch={false} replace href="/function/tasks">
<Button variant="outline">{t("back")}</Button>
</Link>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,144 @@
"use client";
import * as React from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/api";
import type { Activity, InterceptExecution } from "@/lib/types";
// Resolve one exact persisted call, independently of transcript pagination.
export function useApprovalFocus({ taskId, conversationId }: { taskId?: string; conversationId?: number }) {
const t = useTranslations("approvalFocus");
const [state, setState] = React.useState<{
id: number;
source?: InterceptExecution;
error?: string;
loading: boolean;
} | null>(null);
const [revision, setRevision] = React.useState(0);
// biome-ignore lint/correctness/useExhaustiveDependencies: revision is the explicit retry trigger.
React.useEffect(() => {
const q = new URLSearchParams(window.location.search);
const id = Number(q.get("approval"));
const matches = conversationId ? q.get("c") === String(conversationId) : q.get("id") === taskId;
if (!matches || !Number.isSafeInteger(id) || id <= 0) {
setState(null);
return;
}
let cancelled = false;
setState({ id, loading: true });
api
.interceptExecution(id, conversationId)
.then((source) => {
if (cancelled) return;
if (
conversationId
? source.conversation_id !== conversationId
: source.task_id !== taskId || source.conversation_id != null
) {
throw new Error(t("mismatch"));
}
setState({ id, source, loading: false });
})
.catch((e) => {
if (!cancelled) setState({ id, error: (e as Error).message || t("noSource"), loading: false });
});
return () => {
cancelled = true;
};
}, [taskId, conversationId, revision]);
const close = React.useCallback(() => {
const url = new URL(window.location.href);
url.searchParams.delete("approval");
window.history.replaceState(null, "", url);
setState(null);
}, []);
return { state, close, retry: () => setRevision((v) => v + 1) };
}
type HistoryPage = { items: Activity[]; hasMore: boolean };
// Fill the existing transcript continuously back to the call, preserving all
// intervening messages. Never splice an isolated call into a paginated history.
export function useApprovalHistory(
source: InterceptExecution | undefined,
loaded: boolean,
items: Activity[],
loadPage: (before: number) => Promise<HistoryPage>,
mergePage: (page: HistoryPage) => void,
) {
const t = useTranslations("approvalFocus");
const itemsRef = React.useRef(items);
itemsRef.current = items;
const [result, setResult] = React.useState<{ source: InterceptExecution; error?: string }>();
React.useEffect(() => {
if (!source || !loaded) return;
let cancelled = false;
void (async () => {
let current = itemsRef.current;
let before = current[0]?.seq ?? 0;
while (!current.some((a) => a.seq === source.seq && a.kind === "tool_use")) {
const page = await loadPage(before);
if (cancelled) return;
if (!page.items.length || (before > 0 && page.items[0].seq >= before)) {
throw new Error(t("notFoundDeleted"));
}
mergePage(page);
current = page.items;
before = current[0].seq;
if (!page.hasMore && !current.some((a) => a.seq === source.seq)) {
throw new Error(t("notFound"));
}
}
if (!cancelled) setResult({ source });
})().catch((error) => {
if (!cancelled) setResult({ source, error: (error as Error).message });
});
return () => {
cancelled = true;
};
}, [source, loaded, loadPage, mergePage, t]);
return {
ready: !!source && result?.source === source && !result.error,
error: result?.source === source ? result?.error : undefined,
};
}
export function ApprovalExecutionFocus({
focus,
history,
}: {
focus: ReturnType<typeof useApprovalFocus>;
history: ReturnType<typeof useApprovalHistory>;
}) {
const t = useTranslations("approvalFocus");
const { state } = focus;
if (!state) return null;
const error = state.error || history.error;
return (
<div
role="status"
className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b px-4 py-2 text-xs"
>
<span className={error ? "text-destructive" : "text-muted-foreground"}>
{error
? t("locateFail", { error })
: history.ready
? t("expanded", { id: state.id })
: t("loading", { id: state.id })}
</span>
<div className="flex gap-2">
{error ? (
<Button size="sm" variant="outline" onClick={focus.retry}>
{t("retry")}
</Button>
) : null}
<Button size="sm" variant="ghost" onClick={focus.close}>
{t("cancel")}
</Button>
</div>
</div>
);
}
+944
View File
@@ -0,0 +1,944 @@
"use client";
import * as React from "react";
import {
BotIcon,
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
ClipboardListIcon,
CopyIcon,
RefreshCwIcon,
ShieldAlertIcon,
XIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { TablePagination } from "@/components/table-pagination";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api } from "@/lib/api";
import type {
InterceptApprovalFilter,
InterceptApprovalRow,
InterceptDetail,
InterceptPending,
InterceptReviewInput,
} from "@/lib/types";
import { cn } from "@/lib/utils";
type Translator = ReturnType<typeof useTranslations>;
function fmtTime(value?: string) {
if (!value) return "—";
return new Date(value).toLocaleString("ko-KR", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function source(row: InterceptApprovalRow) {
if (row.decision_source) return row.decision_source;
if (row.rule_id) return "rule";
return row.reason?.startsWith("[模型]") ? "model" : "unknown";
}
function originLabel(row: InterceptApprovalRow, t: Translator) {
if (row.task_id) return row.task_id;
if (row.conversation_id) return t("conversationRef", { id: row.conversation_id });
return "—";
}
function ApprovalOrigin({ row, detail = false }: { row: InterceptApprovalRow; detail?: boolean }) {
const t = useTranslations("approval");
const [locating, setLocating] = React.useState(false);
const label = detail && row.task_id ? t("taskRef", { id: row.task_id }) : originLabel(row, t);
const query = new URLSearchParams({ approval: String(row.id) });
let href: string | undefined;
if (row.conversation_id) {
query.set("c", String(row.conversation_id));
href = `/chat?${query}`;
} else if (row.task_id) {
query.set("id", row.task_id);
href = `/function/tasks/detail?${query}`;
}
return href ? (
<a
href={href}
className="text-primary underline-offset-4 hover:underline"
aria-label={t("locateSourceAria", { id: row.id, label })}
aria-busy={locating}
onClick={async (e) => {
e.stopPropagation();
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
if (locating) return;
setLocating(true);
try {
await api.interceptExecution(row.id, row.conversation_id ?? undefined);
window.location.assign(href);
} catch (error) {
toast.error((error as Error).message || t("locateFailed"));
setLocating(false);
}
}}
>
{label}
</a>
) : (
<span>{label}</span>
);
}
function StatusBadge({ status }: { status: string }) {
const t = useTranslations("approval");
let variant: "default" | "destructive" | "secondary" | "outline" = "outline";
if (status === "allowed") variant = "default";
if (status === "denied") variant = "destructive";
if (status === "pending") variant = "secondary";
return <Badge variant={variant}>{t.has(`status.${status}`) ? t(`status.${status}`) : status}</Badge>;
}
function MatchCell({ row, showReason = true }: { row: InterceptApprovalRow; showReason?: boolean }) {
const t = useTranslations("approval");
const reason = row.reason?.replace(/^\[模型\]\s*/, "");
return (
<div className="flex min-w-0 flex-col gap-1">
{source(row) === "model" ? (
<Badge variant="outline">
<BotIcon />
{t("modelDecision")}
</Badge>
) : (
<span className="truncate">{row.rule_name || t("ruleMissing")}</span>
)}
{showReason ? (
<p className="truncate text-muted-foreground text-xs" title={reason}>
{reason || t("noReason")}
</p>
) : null}
</div>
);
}
function CodeBlock({ label, text, truncated = false }: { label: string; text: string; truncated?: boolean }) {
const t = useTranslations("approval");
async function copy() {
try {
await navigator.clipboard.writeText(text);
toast.success(t("copied"));
} catch {
toast.error(t("copyFailed"));
}
}
return (
<section className="flex min-w-0 flex-col gap-2" aria-label={label}>
<div className="flex items-center justify-between gap-2">
<h3 className="font-medium text-muted-foreground text-xs">{label}</h3>
{text ? (
<Button variant="ghost" size="icon-xs" aria-label={t("copyAria", { label })} onClick={() => void copy()}>
<CopyIcon />
</Button>
) : null}
</div>
<pre className="max-h-80 min-w-0 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-muted/60 p-3 font-mono text-xs leading-6 [overflow-wrap:anywhere]">
{text || t("notRecorded")}
</pre>
{truncated ? <p className="text-muted-foreground text-xs">{t("truncatedNote")}</p> : null}
</section>
);
}
function ModelReviewContext({ input }: { input: InterceptReviewInput }) {
const t = useTranslations("approval");
return (
<section className="flex min-w-0 flex-col gap-4" aria-label={t("review.context")}>
<div className="flex flex-col gap-2">
<h3 className="font-medium text-sm">{t("review.context")}</h3>
<p className="text-muted-foreground text-xs">
{t("review.snapshotNote")}
{input.version < 4 ? t("review.legacyInput") : null}
</p>
</div>
<CodeBlock
label={t("review.currentCall")}
text={JSON.stringify({ tool_name: input.tool_name, arguments: input.arguments }, null, 2)}
/>
{input.version >= 2 ? (
input.background ? (
<div className="flex min-w-0 flex-col gap-2">
<CodeBlock
label={input.background.source === "user_message" ? t("review.bgUser") : t("review.bgWorkerLegacy")}
text={input.background.text}
truncated={input.background.truncated}
/>
<p className="text-muted-foreground text-xs">
{input.background.source === "user_message" ? t("review.bgUserNote") : t("review.bgWorkerNote")}
</p>
</div>
) : (
<p className="text-muted-foreground text-xs">{t("review.noBg")}</p>
)
) : (
<>
{input.turn_input ? (
<CodeBlock
label={t("review.turnInputLegacy")}
text={input.turn_input}
truncated={input.background_truncated}
/>
) : null}
{input.task ? (
<>
<CodeBlock
label={t("review.taskDescLegacy")}
text={input.task.description}
truncated={input.task.truncated}
/>
<CodeBlock label={t("review.taskGoalLegacy")} text={input.task.goal} truncated={input.task.truncated} />
<CodeBlock
label={t("review.taskConstraintsLegacy")}
text={JSON.stringify(input.task.constraints, null, 2)}
/>
</>
) : null}
{input.worker_intent ? (
<CodeBlock
label={t("review.workerIntentLegacy")}
text={input.worker_intent}
truncated={input.background_truncated}
/>
) : null}
</>
)}
{input.working_directory ? <CodeBlock label={t("review.workingDir")} text={input.working_directory} /> : null}
{input.version >= 3 ? (
<p className="text-muted-foreground text-xs">{t("review.noHistory")}</p>
) : (
<div className="flex min-w-0 flex-col gap-3">
<h4 className="font-medium text-muted-foreground text-xs">{t("review.historyTitleLegacy")}</h4>
{input.history?.length ? (
input.history.map((entry) => (
<div key={entry.tool_use_id} className="flex min-w-0 flex-col gap-2 rounded-lg border p-3">
<p className="break-words font-medium text-xs">
{entry.tool} · {entry.status === "succeeded" ? t("review.histSucceeded") : t("review.histFailed")}
</p>
<CodeBlock label={t("review.histArgs")} text={entry.arguments_preview} truncated={entry.truncated} />
<CodeBlock label={t("review.histResult")} text={entry.result} truncated={entry.truncated} />
</div>
))
) : (
<p className="text-muted-foreground text-xs">{t("review.noPairable")}</p>
)}
{input.history_truncated ? (
<p className="text-muted-foreground text-xs">{t("review.histTruncated")}</p>
) : null}
</div>
)}
<Collapsible>
<CollapsibleTrigger asChild>
<Button variant="outline" size="sm" className="self-start">
<ChevronDownIcon data-icon="inline-start" />
{t("review.viewFullJson")}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<CodeBlock label={t("review.modelInput")} text={JSON.stringify(input, null, 2)} />
</CollapsibleContent>
</Collapsible>
</section>
);
}
type Decide = (id: number, decision: "allowed" | "denied") => Promise<void>;
function DecisionActions({ row, busy, decide }: { row: InterceptApprovalRow; busy: boolean; decide: Decide }) {
const t = useTranslations("approval");
if (row.status !== "pending") return null;
return (
<div className="flex flex-wrap gap-2">
<Button size="sm" disabled={busy} onClick={() => void decide(row.id, "allowed")}>
<CheckIcon data-icon="inline-start" />
{t("allow")}
</Button>
<Button size="sm" variant="destructive" disabled={busy} onClick={() => void decide(row.id, "denied")}>
<XIcon data-icon="inline-start" />
{t("deny")}
</Button>
</div>
);
}
export function ApprovalDetail({
row,
busy,
decide,
revision,
readOnly = false,
defaultExpanded = false,
onResolved,
}: {
row: InterceptApprovalRow;
busy: boolean;
decide: Decide;
revision: number;
readOnly?: boolean;
defaultExpanded?: boolean;
onResolved?: (status: "allowed" | "denied" | "timeout") => void;
}) {
const t = useTranslations("approval");
const [detail, setDetail] = React.useState<InterceptDetail | null>(null);
const [error, setError] = React.useState("");
const [retry, setRetry] = React.useState(0);
const [more, setMore] = React.useState(defaultExpanded);
// biome-ignore lint/correctness/useExhaustiveDependencies: Status, refresh and retry invalidate details without closing the panel.
React.useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
async function load() {
try {
const next = await api.interceptDetail(row.id);
if (cancelled) return;
setDetail(next);
setError("");
if (next.status !== "pending") onResolved?.(next.status);
if (next.status === "pending" || next.audit?.execution_status === "awaiting_result")
timer = setTimeout(() => void load(), 5000);
} catch (e) {
if (!cancelled) setError((e as Error).message || t("detailLoadFailed"));
}
}
void load();
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [row.id, row.status, revision, retry, onResolved, t]);
// Row updates are authoritative until the lazy detail has caught up.
const current = detail?.status === row.status ? detail : row;
const audit = detail?.audit;
let execution = audit ? t(`execution.${audit.execution_status}`) : t("notRecorded");
if (row.status === "pending") execution = t("execution.not_started");
if (audit && audit.correlation !== "exact" && audit.effective_action === "allow")
execution = t("execution.notCorrelated");
const command = typeof row.tool_input?.command === "string" ? row.tool_input.command : undefined;
let initialLabel = source(row) === "model" ? t("modelInitial") : t("ruleInitial");
if (audit?.model_fallback) initialLabel = t("modelFallback");
const agentName = current.agent_name || current.conv_agent_key || t("agentFallback");
return (
<div className="flex min-w-0 flex-col gap-4 p-3 sm:p-5">
<div className="grid min-w-0 gap-5 rounded-xl border bg-muted/20 p-4 lg:grid-cols-2">
<div className="flex min-w-0 flex-col gap-3">
<CodeBlock
label={t("toolRequest", { agent: agentName })}
text={JSON.stringify(row.tool_input ?? {}, null, 2)}
/>
{command ? (
<Collapsible>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm">
<ChevronDownIcon data-icon="inline-start" />
{t("viewCommand")}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
<CodeBlock label={t("commandContent")} text={command} />
</CollapsibleContent>
</Collapsible>
) : null}
<p className="text-muted-foreground text-xs">
{t("executionResult")}: <span className="text-foreground">{detail ? execution : t("loading")}</span>
</p>
</div>
<div className="flex min-w-0 flex-col gap-4 lg:border-l lg:pl-5">
<h3 className="font-medium text-muted-foreground text-xs">
{current.status === "pending" ? t("reviewStatus") : t("approvalVerdict")}
</h3>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge status={current.status} />
<MatchCell row={current} showReason={false} />
</div>
<p className="whitespace-pre-wrap break-words text-sm leading-7 [overflow-wrap:anywhere]">
{current.reason?.replace(/^\[模型\]\s*/, "") || t("noApprovalReason")}
</p>
{audit?.decision_reason ? <p className="text-sm">{audit.decision_reason}</p> : null}
{audit?.effective_action ? (
<p className="text-sm">
{t("finalAction")}: {t(`action.${audit.effective_action}`)}
</p>
) : null}
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-xs">
<dt className="text-muted-foreground">{t("sourceLabel")}</dt>
<dd className="break-words">
<ApprovalOrigin row={current} detail />
</dd>
<dt className="text-muted-foreground">{t("requestedAt")}</dt>
<dd>{fmtTime(row.created_at)}</dd>
<dt className="text-muted-foreground">{t("decidedAt")}</dt>
<dd>{fmtTime(current.decided_at)}</dd>
{audit?.rule_name ? (
<>
<dt className="text-muted-foreground">{t("ruleSnapshot")}</dt>
<dd>{audit.rule_name}</dd>
</>
) : null}
{audit?.profile_id ? (
<>
<dt className="text-muted-foreground">{t("approvalModelProfile")}</dt>
<dd>#{audit.profile_id}</dd>
</>
) : null}
</dl>
{!readOnly ? <DecisionActions row={current} busy={busy} decide={decide} /> : null}
</div>
</div>
{error ? (
<Alert variant="destructive">
<AlertDescription>
<div className="flex flex-wrap items-center gap-2">
<span>{t("detailLoadFailedWith", { error })}</span>
<Button variant="outline" size="sm" onClick={() => setRetry((v) => v + 1)}>
{t("retryDetail")}
</Button>
</div>
</AlertDescription>
</Alert>
) : null}
{!detail && !error ? <Skeleton className="h-8 w-60" /> : null}
{detail && !audit ? (
<Alert>
<AlertDescription>{t("noDetailSnapshot")}</AlertDescription>
</Alert>
) : null}
{audit ? (
<Collapsible open={more} onOpenChange={setMore}>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm">
<ChevronDownIcon data-icon="inline-start" className={cn(more && "rotate-180")} />
{more ? t("collapseContext") : t("viewContextAndResult")}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-4">
<div className="flex min-w-0 flex-col gap-5">
{audit.model_input ? (
<ModelReviewContext input={audit.model_input} />
) : (
<Alert>
<AlertDescription>
{audit.model_input_digest ? t("digestOnlyNote") : t("noModelInputNote")}
</AlertDescription>
</Alert>
)}
{audit.user_message || audit.context?.length ? (
<Collapsible>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm">
<ChevronDownIcon data-icon="inline-start" />
{audit.model_input && audit.model_input.version >= 3
? t("viewAuditFragmentNotSent")
: t("viewAuditFragment")}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<div className="flex min-w-0 flex-col gap-3">
{audit.user_message ? (
<CodeBlock
label={t("auditCurrentTurn")}
text={audit.user_message}
truncated={audit.user_truncated}
/>
) : null}
<section className="flex min-w-0 flex-col gap-3">
<h3 className="font-medium text-muted-foreground text-xs">{t("visibleContext")}</h3>
<p className="text-muted-foreground text-xs">
{t("savedContextNote", { time: fmtTime(audit.captured_at) })}
</p>
{audit.context_truncated ? (
<p className="text-muted-foreground text-xs">{t("contextTruncated")}</p>
) : null}
{audit.context?.length ? (
audit.context.map((entry, index) => (
<CodeBlock
key={`${entry.kind}-${entry.tool_use_id || index}`}
label={`${t.has(`context.${entry.kind}`) ? t(`context.${entry.kind}`) : entry.kind}${entry.tool ? ` · ${entry.tool}` : ""}${entry.is_error ? ` · ${t("errorTag")}` : ""}`}
text={entry.text}
truncated={entry.truncated}
/>
))
) : (
<p className="text-muted-foreground text-sm">{t("noRelatedContext")}</p>
)}
</section>
</div>
</CollapsibleContent>
</Collapsible>
) : null}
<CodeBlock
label={`${initialLabel}: ${t.has(`action.${audit.initial_action}`) ? t(`action.${audit.initial_action}`) : audit.initial_action}`}
text={audit.initial_reason.replace(/^\[模型\]\s*/, "")}
/>
<CodeBlock
label={t("executionOutput")}
text={
audit.output ?? (audit.execution_status === "not_executed" ? t("toolNotExecuted") : t("noOutputYet"))
}
truncated={audit.output_truncated}
/>
{audit.correlation !== "exact" ? (
<Alert>
<AlertDescription>
{audit.correlation === "ambiguous" ? t("ambiguousCorrelation") : t("noUniqueToolId")}
</AlertDescription>
</Alert>
) : null}
<dl className="grid gap-2 text-muted-foreground text-xs [overflow-wrap:anywhere]">
<div>
{t("toolCallId")}: {audit.tool_use_id || t("notRecorded")}
</div>
<div>
{t("inputDigest")}: {audit.input_digest}
</div>
<div>
{t("configDigest")}: {audit.config_digest || t("notRecorded")}
</div>
{audit.model_input_digest ? (
<div>
{t("modelInputDigest")}: {audit.model_input_digest}
</div>
) : null}
{audit.execution_ended_at ? (
<div>
{t("resultRecordedAt")}: {fmtTime(audit.execution_ended_at)}
</div>
) : null}
</dl>
</div>
</CollapsibleContent>
</Collapsible>
) : null}
</div>
);
}
// Hidden cells do not occupy table columns. Keep the detail span in sync so
// expansion cannot create empty columns and leave a gap in the selected row.
const approvalBreakpoints = ["(min-width: 40rem)", "(min-width: 48rem)", "(min-width: 64rem)", "(min-width: 80rem)"];
function subscribeColumns(onChange: () => void) {
const queries = approvalBreakpoints.map((query) => window.matchMedia(query));
for (const query of queries) query.addEventListener("change", onChange);
return () => {
for (const query of queries) query.removeEventListener("change", onChange);
};
}
function visibleColumnCount() {
const matches = approvalBreakpoints.map((query) => window.matchMedia(query).matches);
return 3 + Number(matches[0]) + Number(matches[1]) + 2 * Number(matches[2]) + 2 * Number(matches[3]);
}
function serverColumnCount() {
return 9;
}
function ApprovalTable({
rows,
busy,
decide,
revision,
label,
}: {
rows: InterceptApprovalRow[];
busy: boolean;
decide: Decide;
revision: number;
label: string;
}) {
const t = useTranslations("approval");
const [expanded, setExpanded] = React.useState<Set<number>>(() => new Set());
const columns = React.useSyncExternalStore(subscribeColumns, visibleColumnCount, serverColumnCount);
const prefix = React.useId();
function toggle(id: number) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
return (
<Table className="table-fixed" aria-label={label}>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<span className="sr-only">{t("table.expandDetail")}</span>
</TableHead>
<TableHead className="hidden w-14 sm:table-cell">#</TableHead>
<TableHead className="w-28">{t("table.tool")}</TableHead>
<TableHead className="hidden md:table-cell">{t("table.source")}</TableHead>
<TableHead className="hidden lg:table-cell">{t("table.matchRule")}</TableHead>
<TableHead className="hidden xl:table-cell">{t("table.params")}</TableHead>
<TableHead className="w-24">{t("table.status")}</TableHead>
<TableHead className="hidden w-36 lg:table-cell">{t("requestedAt")}</TableHead>
<TableHead className="hidden w-36 xl:table-cell">{t("decidedAt")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const open = expanded.has(row.id);
const panelID = `${prefix}-${row.id}`;
return (
<React.Fragment key={row.id}>
<TableRow
data-state={open ? "selected" : undefined}
className="cursor-pointer"
onClick={() => toggle(row.id)}
>
<TableCell>
<Button
variant="ghost"
size="icon-sm"
aria-label={t("row.toggleAria", { action: open ? t("collapse") : t("expand"), id: row.id })}
aria-expanded={open}
aria-controls={open ? panelID : undefined}
onClick={(e) => {
e.stopPropagation();
toggle(row.id);
}}
>
{open ? <ChevronDownIcon /> : <ChevronRightIcon />}
</Button>
</TableCell>
<TableCell className="hidden text-muted-foreground sm:table-cell">{row.id}</TableCell>
<TableCell>
<code className="block truncate text-xs">{row.tool_name}</code>
</TableCell>
<TableCell className="hidden md:table-cell">
<div className="flex flex-col gap-1">
<span className="truncate" title={originLabel(row, t)}>
<ApprovalOrigin row={row} />
</span>
<span className="truncate text-muted-foreground text-xs">
{row.agent_name || row.conv_agent_key}
</span>
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">
<MatchCell row={row} />
</TableCell>
<TableCell className="hidden xl:table-cell">
<code className="block truncate text-muted-foreground text-xs">{JSON.stringify(row.tool_input)}</code>
</TableCell>
<TableCell>
<StatusBadge status={row.status} />
</TableCell>
<TableCell className="hidden text-muted-foreground text-xs lg:table-cell">
{fmtTime(row.created_at)}
</TableCell>
<TableCell className="hidden text-muted-foreground text-xs xl:table-cell">
{fmtTime(row.decided_at)}
</TableCell>
</TableRow>
{open ? (
<TableRow className="hover:bg-transparent has-aria-expanded:bg-transparent">
<TableCell colSpan={columns} className="whitespace-normal p-0">
<section id={panelID} aria-label={t("detailAria", { id: row.id })}>
<ApprovalDetail row={row} busy={busy} decide={decide} revision={revision} />
</section>
</TableCell>
</TableRow>
) : null}
</React.Fragment>
);
})}
</TableBody>
</Table>
);
}
export function ApprovalRecords({ taskId }: { taskId?: string }) {
const t = useTranslations("approval");
const [rows, setRows] = React.useState<InterceptApprovalRow[]>([]);
const [pendingRows, setPendingRows] = React.useState<InterceptPending[]>([]);
const [page, setPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(20);
const [total, setTotal] = React.useState(0);
const [filter, setFilter] = React.useState<InterceptApprovalFilter>({});
const [loading, setLoading] = React.useState(true);
const [refreshing, setRefreshing] = React.useState(false);
const [error, setError] = React.useState("");
const [pendingError, setPendingError] = React.useState("");
const [deciding, setDeciding] = React.useState(false);
const [revision, setRevision] = React.useState(0);
const request = React.useRef(0);
const decisionLock = React.useRef(false);
const filterID = React.useId();
const filtered = Boolean(filter.status || filter.decision_source);
// A task can stay mounted while the user switches between task details.
// Reset the cursor so the new scope always starts at its newest records.
// biome-ignore lint/correctness/useExhaustiveDependencies: taskId intentionally resets pagination when scope changes.
React.useEffect(() => {
setPage(1);
}, [taskId]);
const load = React.useCallback(
async (manual = false) => {
const id = ++request.current;
if (manual) setRefreshing(true);
try {
// The approval queue is independent of the current history page and its
// filter. Older requests must remain actionable even when newer decisions
// fill the page or a filter would hide them.
const [history, pending] = await Promise.allSettled([
taskId
? api.interceptTaskPage(taskId, page, pageSize, filter)
: api.interceptHistoryPage(page, pageSize, filter),
api.interceptPending(),
]);
if (id !== request.current) return;
if (history.status === "fulfilled") {
setRows(history.value.items);
setTotal(history.value.total);
setError("");
} else {
setError((history.reason as Error).message || t("loadError"));
}
if (pending.status === "fulfilled") {
setPendingRows(pending.value);
setPendingError("");
} else {
setPendingError((pending.reason as Error).message || t("loadError"));
}
if (manual) setRevision((v) => v + 1);
} catch (e) {
if (id === request.current) setError((e as Error).message || t("loadError"));
} finally {
if (id === request.current) {
setLoading(false);
setRefreshing(false);
}
}
},
[taskId, page, pageSize, filter, t],
);
const latestLoad = React.useRef(load);
latestLoad.current = load;
React.useEffect(() => {
void load();
const timer = setInterval(() => void load(), 5000);
return () => {
request.current++;
clearInterval(timer);
};
}, [load]);
const changePageSize = (next: number) => {
setPageSize(next);
setPage(1);
};
const changeFilter = (next: InterceptApprovalFilter) => {
// An in-flight response for the old filter must not repopulate the table.
request.current++;
setFilter(next);
setPage(1);
setRows([]);
setTotal(0);
setError("");
setLoading(true);
};
const decide: Decide = async (id, decision) => {
if (decisionLock.current) return;
decisionLock.current = true;
setDeciding(true);
try {
await api.interceptDecide(id, decision);
// Invalidate a list request started before this decision.
request.current++;
// Optimistically drop the decided item from the independent pending queue
// for instant feedback; the re-fetch below reconciles with server truth.
setRows((prev) =>
prev.map((row) => (row.id === id ? { ...row, status: decision, decided_at: new Date().toISOString() } : row)),
);
setPendingRows((prev) => prev.filter((row) => row.id !== id));
setRevision((v) => v + 1);
toast.success(decision === "allowed" ? t("allowedExecution") : t("deniedExecution"));
// Re-fetch counts and rows: a decided item may no longer match the filter.
await latestLoad.current(true);
} catch (e) {
toast.error((e as Error).message);
await latestLoad.current(true);
} finally {
decisionLock.current = false;
setDeciding(false);
}
};
const historyById = new Map(rows.map((row) => [row.id, row]));
const pending: InterceptApprovalRow[] = pendingRows
.filter((row) => !taskId || row.task_id === taskId)
.map((row) => ({
conv_title: "",
conv_agent_key: "",
rule_name: row.rule_id ? t("ruleRef", { id: row.rule_id }) : "",
...historyById.get(row.id),
...row,
}));
const title = taskId ? t("title.task") : t("title.history");
return (
<div className="flex min-w-0 flex-col gap-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<ClipboardListIcon className="size-5 text-muted-foreground" />
<h1 className="font-semibold text-xl">{title}</h1>
{pending.length ? <Badge variant="secondary">{t("pendingBadge", { count: pending.length })}</Badge> : null}
</div>
<Button variant="outline" size="sm" onClick={() => void load(true)} disabled={loading || refreshing}>
<RefreshCwIcon data-icon="inline-start" className={cn(refreshing && "animate-spin")} />
{t("refresh")}
</Button>
</div>
<p className="text-muted-foreground text-sm">{t("intro")}</p>
<FieldGroup className="flex-row flex-wrap items-end gap-3" aria-label={t("filter.label")}>
<Field className="w-full sm:w-40">
<FieldLabel htmlFor={`${filterID}-status`}>{t("filter.status")}</FieldLabel>
<Select
value={filter.status ?? "all"}
onValueChange={(value) =>
changeFilter({
...filter,
status: value === "all" ? undefined : (value as InterceptApprovalFilter["status"]),
})
}
>
<SelectTrigger id={`${filterID}-status`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">{t("filter.statusAll")}</SelectItem>
<SelectItem value="denied">{t("status.denied")}</SelectItem>
<SelectItem value="pending">{t("status.pending")}</SelectItem>
<SelectItem value="allowed">{t("status.allowed")}</SelectItem>
<SelectItem value="timeout">{t("status.timeout")}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field className="w-full sm:w-40">
<FieldLabel htmlFor={`${filterID}-source`}>{t("filter.source")}</FieldLabel>
<Select
value={filter.decision_source ?? "all"}
onValueChange={(value) =>
changeFilter({
...filter,
decision_source: value === "all" ? undefined : (value as InterceptApprovalFilter["decision_source"]),
})
}
>
<SelectTrigger id={`${filterID}-source`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">{t("filter.sourceAll")}</SelectItem>
<SelectItem value="model">{t("sourceOption.model")}</SelectItem>
<SelectItem value="rule">{t("sourceOption.rule")}</SelectItem>
<SelectItem value="unknown">{t("sourceOption.unknown")}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
{filtered ? (
<Button variant="ghost" size="sm" onClick={() => changeFilter({})}>
{t("filter.clear")}
</Button>
) : null}
</FieldGroup>
{error ? (
<Alert variant="destructive">
<AlertDescription>{t("recordLoadError", { error })}</AlertDescription>
</Alert>
) : null}
{pendingError ? (
<Alert variant="destructive">
<AlertDescription>{t("pendingLoadError", { error: pendingError })}</AlertDescription>
</Alert>
) : null}
{pending.length ? (
<section className="overflow-hidden rounded-xl border">
<div className="flex items-center gap-2 border-b bg-muted/40 px-4 py-3 font-medium text-sm">
<ShieldAlertIcon className="size-4" />
{t("pendingSection", { count: pending.length })}{" "}
<span className="text-muted-foreground text-xs">{t("pendingHint")}</span>
</div>
<ApprovalTable
rows={pending}
busy={deciding}
decide={decide}
revision={revision}
label={t("pendingTableLabel")}
/>
</section>
) : null}
<section className="overflow-hidden rounded-xl border">
<div className="border-b px-4 py-3 font-medium text-sm">
{filtered ? t("filteredResults") : t("allRecords")} ({total})
</div>
{loading ? (
<div className="flex flex-col gap-3 p-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : null}
{!loading && !rows.length && !error ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<ClipboardListIcon />
</EmptyMedia>
<EmptyTitle>{filtered ? t("emptyFiltered") : t("emptyTitle")}</EmptyTitle>
<EmptyDescription>{filtered ? t("emptyFilteredDesc") : t("emptyDesc")}</EmptyDescription>
</EmptyHeader>
</Empty>
) : null}
{rows.length ? (
<ApprovalTable
rows={rows}
busy={deciding}
decide={decide}
revision={revision}
label={t("historyTableLabel")}
/>
) : null}
{!loading && !error ? (
<TablePagination
page={page}
pageSize={pageSize}
total={total}
onPageChange={setPage}
onPageSizeChange={changePageSize}
pageSizeOptions={[10, 20, 50]}
/>
) : null}
</section>
</div>
);
}
+272
View File
@@ -0,0 +1,272 @@
"use client";
import * as React from "react";
import { SearchIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
// ── DSL autocomplete ──────────────────────────────────────────────────────────
// Shared by the global asset view (/function/assets) and the per-task 测试资产
// search, so both search boxes behave and look identical. Field/operator/logic
// descriptions are translation keys (namespace "assetSearch") resolved at render.
const STR_OPS = [
{ op: "=", descKey: "op.fuzzy" },
{ op: "==", descKey: "op.exact" },
{ op: "!=", descKey: "op.exclude" },
];
const EXACT_OPS = [
{ op: "==", descKey: "op.exact" },
{ op: "!=", descKey: "op.exclude" },
];
const NUM_OPS = [
{ op: "==", descKey: "op.eq" },
{ op: "!=", descKey: "op.ne" },
{ op: ">", descKey: "op.gt" },
{ op: ">=", descKey: "op.gte" },
{ op: "<", descKey: "op.lt" },
{ op: "<=", descKey: "op.lte" },
];
const EQ_OPS = [{ op: "==", descKey: "op.eq" }];
const DSL_FIELDS: { name: string; descKey: string; ops: { op: string; descKey: string }[] }[] = [
{ name: "domain", descKey: "field.domain", ops: STR_OPS },
{ name: "ip", descKey: "field.ip", ops: STR_OPS },
{ name: "url", descKey: "field.url", ops: STR_OPS },
{ name: "root_domain", descKey: "field.root_domain", ops: STR_OPS },
{ name: "page_title", descKey: "field.page_title", ops: STR_OPS },
{ name: "icp", descKey: "field.icp", ops: STR_OPS },
{ name: "service_name", descKey: "field.service_name", ops: STR_OPS },
{ name: "app_name", descKey: "field.app_name", ops: STR_OPS },
{ name: "bundle_id", descKey: "field.bundle_id", ops: STR_OPS },
{ name: "category", descKey: "field.category", ops: STR_OPS },
{ name: "app_icp", descKey: "field.app_icp", ops: STR_OPS },
{ name: "method", descKey: "field.method", ops: EXACT_OPS },
{ name: "service_type", descKey: "field.service_type", ops: EXACT_OPS },
{ name: "record_type", descKey: "field.record_type", ops: EXACT_OPS },
{ name: "technology", descKey: "field.technology", ops: STR_OPS },
{ name: "port", descKey: "field.port", ops: NUM_OPS },
{ name: "status_code", descKey: "field.status_code", ops: NUM_OPS },
{ name: "company_id", descKey: "field.company_id", ops: EQ_OPS },
{ name: "task_id", descKey: "field.task_id", ops: EQ_OPS },
];
const LOGIC_OPS = [
{ label: "AND", descKey: "logic.and" },
{ label: "OR", descKey: "logic.or" },
];
interface DslSuggestion {
kind: "field" | "operator" | "logic";
label: string;
descKey: string;
replaceStart: number;
replaceEnd: number;
insertText: string;
}
function getDslSuggestions(text: string, cursor: number): DslSuggestion[] {
const before = text.slice(0, cursor);
// Current token: non-whitespace, non-paren run ending at cursor
const tokenMatch = before.match(/([^\s()]*$)/);
const currentToken = tokenMatch?.[1] ?? "";
const tokenStart = cursor - currentToken.length;
// Token already contains field+operator → typing a value, no suggestions
if (/^[a-z_]+(==|!=|>=|<=|=|>|<)/.test(currentToken)) return [];
// Complete known field name → suggest operators for that field
const exactField = DSL_FIELDS.find((f) => f.name === currentToken.toLowerCase());
if (exactField) {
return exactField.ops.map(({ op, descKey }) => ({
kind: "operator",
label: `${exactField.name}${op}`,
descKey,
replaceStart: tokenStart,
replaceEnd: cursor,
insertText: `${exactField.name}${op}`,
}));
}
// Everything before the current token (trimmed)
const beforeToken = before.slice(0, tokenStart).trimEnd();
const afterExpression = beforeToken.length > 0 && !/\b(AND|OR)\s*$/i.test(beforeToken) && !beforeToken.endsWith("(");
// Current token is a prefix of AND/OR and follows a complete expression
if (/^(a|an|and|o|or)$/i.test(currentToken) && afterExpression) {
return LOGIC_OPS.filter((l) => l.label.startsWith(currentToken.toUpperCase())).map(({ label, descKey }) => ({
kind: "logic",
label,
descKey,
replaceStart: tokenStart,
replaceEnd: cursor,
insertText: `${label} `,
}));
}
// No current token, after a complete expression → suggest AND/OR
if (!currentToken && afterExpression) {
return LOGIC_OPS.map(({ label, descKey }) => ({
kind: "logic",
label,
descKey,
replaceStart: cursor,
replaceEnd: cursor,
insertText: `${label} `,
}));
}
// Default: suggest fields filtered by prefix
const prefix = currentToken.toLowerCase();
return DSL_FIELDS.filter((f) => f.name.startsWith(prefix)).map((f) => ({
kind: "field",
label: f.name,
descKey: f.descKey,
replaceStart: tokenStart,
replaceEnd: cursor,
insertText: f.name,
}));
}
function applyDslSuggestion(text: string, s: DslSuggestion): { text: string; cursor: number } {
const newText = text.slice(0, s.replaceStart) + s.insertText + text.slice(s.replaceEnd);
return { text: newText, cursor: s.replaceStart + s.insertText.length };
}
const KIND_STYLE: Record<string, string> = {
field: "text-blue-500 dark:text-blue-400",
operator: "text-amber-500 dark:text-amber-400",
logic: "text-emerald-500 dark:text-emerald-400",
};
// AssetDslSearch is the shared DSL search box: a monospace input with a
// field/operator/logic autocomplete popover and a status line (result count /
// error / loading). Used by both the global asset view and the per-task view.
export function AssetDslSearch({
query,
onChange,
loading,
error,
count,
}: {
query: string;
onChange: (v: string) => void;
loading: boolean;
error: string;
count?: number;
}) {
const t = useTranslations("assetSearch");
const inputRef = React.useRef<HTMLInputElement>(null);
const [suggestions, setSuggestions] = React.useState<DslSuggestion[]>([]);
const [selIdx, setSelIdx] = React.useState(0);
const [open, setOpen] = React.useState(false);
const refresh = React.useCallback((val: string, pos: number) => {
const suggs = getDslSuggestions(val, pos);
setSuggestions(suggs);
setSelIdx(0);
setOpen(suggs.length > 0);
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
onChange(val);
refresh(val, e.target.selectionStart ?? val.length);
};
const apply = React.useCallback(
(s: DslSuggestion) => {
const cursor = inputRef.current?.selectionStart ?? query.length;
// use cursor for logic-kind (insert at cursor), replaceStart/End for others
const adjusted: DslSuggestion =
s.kind === "logic" && !query.slice(s.replaceStart, s.replaceEnd)
? { ...s, replaceStart: cursor, replaceEnd: cursor }
: s;
const { text: newText, cursor: newCursor } = applyDslSuggestion(query, adjusted);
onChange(newText);
requestAnimationFrame(() => {
if (!inputRef.current) return;
inputRef.current.setSelectionRange(newCursor, newCursor);
inputRef.current.focus();
refresh(newText, newCursor);
});
},
[query, onChange, refresh],
);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!open || suggestions.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setSelIdx((i) => Math.min(i + 1, suggestions.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelIdx((i) => Math.max(i - 1, 0));
} else if (e.key === "Tab" || e.key === "Enter") {
const s = suggestions[selIdx];
if (s) {
e.preventDefault();
apply(s);
}
} else if (e.key === "Escape") {
setOpen(false);
}
};
const cursorPos = () => inputRef.current?.selectionStart ?? query.length;
return (
<div className="flex flex-col gap-1">
<div className="relative max-w-lg">
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={inputRef}
placeholder={t("placeholder")}
value={query}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={() => refresh(query, cursorPos())}
onClick={() => refresh(query, cursorPos())}
onBlur={() => setTimeout(() => setOpen(false), 120)}
className="h-8 pl-8 font-mono text-xs"
/>
{open && suggestions.length > 0 && (
<div className="absolute top-full left-0 z-50 mt-1 w-max min-w-full max-w-sm rounded-md border bg-popover py-1 shadow-md">
{suggestions.map((s, i) => (
<button
type="button"
key={i}
className={cn(
"flex w-full cursor-pointer items-center gap-3 px-3 py-1.5 text-left",
i === selIdx ? "bg-accent" : "hover:bg-accent/50",
)}
onMouseEnter={() => setSelIdx(i)}
onMouseDown={(e) => {
e.preventDefault();
apply(s);
}}
>
<span className={cn("shrink-0 font-mono text-xs font-semibold", KIND_STYLE[s.kind])}>{s.label}</span>
<span className="text-xs text-muted-foreground">{t(s.descKey)}</span>
</button>
))}
</div>
)}
</div>
{query.trim() && !open && (
<p className="pl-1 text-[11px] text-muted-foreground">
{loading ? (
t("loading")
) : error ? (
<span className="text-destructive">{error}</span>
) : (
t("found", { count: count ?? 0 })
)}
</p>
)}
</div>
);
}
@@ -0,0 +1,105 @@
"use client";
import * as React from "react";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import type { AssetInterceptKind, AssetInterceptRuleInput } from "@/lib/types";
// 用 NativeSelect(原生 <select>)而非 shadcn Select:这个编辑器会用在 Sheet 抽屉内,
// shadcn Select 的下拉 portal 到 body、点击外部会触发抽屉的「点击外部关闭」误关;原生下拉无此问题。
// label 은 렌더 시점에 t(`kind.${value}`) 로 해석(ASSET_INTERCEPT_KIND_OPTIONS 외부 소비처 없음).
export const ASSET_INTERCEPT_KIND_OPTIONS: {
value: AssetInterceptKind;
placeholder: string;
}[] = [
{ value: "exact_domain", placeholder: "example.gov.cn" },
{ value: "exact_ip", placeholder: "203.0.113.10" },
{ value: "exact_url", placeholder: "https://example.com/login" },
{ value: "fuzzy_domain", placeholder: ".gov.cn" },
{ value: "fuzzy_ip", placeholder: "203.0.113." },
{ value: "fuzzy_url", placeholder: "/admin" },
{ value: "cidr", placeholder: "192.168.0.0/16" },
];
// AssetInterceptRulesEditor 是「拦截/允许规则」的受控多行编辑区(拦截block/允许allow +
// 类型 + 匹配内容 + 备注),不自带持久化——由父组件决定何时提交。
export function AssetInterceptRulesEditor({
value,
onChange,
}: {
value: AssetInterceptRuleInput[];
onChange: (v: AssetInterceptRuleInput[]) => void;
}) {
const t = useTranslations("assetInterceptRules");
function update(i: number, patch: Partial<AssetInterceptRuleInput>) {
onChange(value.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
}
function remove(i: number) {
onChange(value.filter((_, idx) => idx !== i));
}
function add() {
onChange([...value, { action: "block", kind: "fuzzy_domain", pattern: "", note: "", enabled: true }]);
}
return (
<div className="grid gap-2">
{value.map((r, i) => {
const ph = ASSET_INTERCEPT_KIND_OPTIONS.find((o) => o.value === r.kind)?.placeholder ?? "";
return (
// biome-ignore lint/suspicious/noArrayIndexKey: 行无稳定 id,按索引受控即可
<div key={i} className="flex items-center gap-2">
<NativeSelect
size="sm"
className="w-[84px] shrink-0"
value={r.action}
onChange={(e) => update(i, { action: e.target.value as "block" | "allow" })}
>
<NativeSelectOption value="block">{t("action.block")}</NativeSelectOption>
<NativeSelectOption value="allow">{t("action.allow")}</NativeSelectOption>
</NativeSelect>
<NativeSelect
size="sm"
className="w-[120px] shrink-0"
value={r.kind}
onChange={(e) => update(i, { kind: e.target.value as AssetInterceptKind })}
>
{ASSET_INTERCEPT_KIND_OPTIONS.map((o) => (
<NativeSelectOption key={o.value} value={o.value}>
{t(`kind.${o.value}`)}
</NativeSelectOption>
))}
</NativeSelect>
<Input
className="flex-1"
placeholder={ph}
value={r.pattern}
onChange={(e) => update(i, { pattern: e.target.value })}
/>
<Input
className="w-[120px] shrink-0"
placeholder={t("notePlaceholder")}
value={r.note}
onChange={(e) => update(i, { note: e.target.value })}
/>
<Button
type="button"
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive size-8 shrink-0"
onClick={() => remove(i)}
>
<Trash2Icon className="size-4" />
</Button>
</div>
);
})}
<Button type="button" size="sm" variant="outline" className="w-fit" onClick={add}>
<PlusIcon className="size-4" /> {t("add")}
</Button>
</div>
);
}
@@ -0,0 +1,497 @@
import FullCalendar, { type CalendarOptions, type DayCellInfo } from '@fullcalendar/react'
import '@fullcalendar/react/skeleton.css'
import { cn } from '@/lib/utils'
const xxsTextClass = 'text-[0.6875rem]/[1.090909]'
const blockPointerResizerClass = 'absolute hidden group-hover:block'
const rowPointerResizerClass = `${blockPointerResizerClass} inset-y-0 w-2`
const columnPointerResizerClass = `${blockPointerResizerClass} inset-x-0 h-2`
const blockTouchResizerClass = 'absolute size-2 border border-(--fc-event-color) bg-background rounded-full'
const rowTouchResizerClass = `${blockTouchResizerClass} top-1/2 -mt-1`
const columnTouchResizerClass = `${blockTouchResizerClass} left-1/2 -ml-1`
const tallDayCellBottomClass = 'min-h-3'
const getShortDayCellBottomClass = (info: DayCellInfo) => cn(
!info.isNarrow && 'min-h-0.5'
)
const dayRowCommonClasses: CalendarOptions = {
/* Day Row > List-Item Event
----------------------------------------------------------------------------------------------- */
listItemEventClass: (info) => cn(
'mb-px p-px rounded-sm',
info.isNarrow ? 'mx-0.5' : 'mx-1',
info.isSelected
? 'bg-foreground/5'
: 'hover:bg-foreground/5',
),
listItemEventInnerClass: (info) => cn(
'flex flex-row items-center justify-between',
info.isNarrow
? `py-px ${xxsTextClass}`
: 'py-0.5 text-xs',
),
listItemEventTimeClass: (info) => cn(
info.isNarrow ? 'px-px' : 'px-0.5',
'order-1 whitespace-nowrap overflow-hidden shrink-1',
),
listItemEventTitleClass: (info) => cn(
info.isNarrow ? 'px-px' : 'px-0.5',
'font-medium whitespace-nowrap overflow-hidden shrink-100',
info.timeText && 'text-ellipsis',
),
/* Day Row > Row Event
----------------------------------------------------------------------------------------------- */
rowEventClass: (info) => cn(
info.isStart && (info.isNarrow ? 'ms-0.5' : 'ms-1'),
info.isEnd && (info.isNarrow ? 'me-0.5' : 'me-1'),
),
rowEventInnerClass: (info) => cn(info.isNarrow ? 'py-px' : 'py-0.5'),
/* Day Row > More-Link
----------------------------------------------------------------------------------------------- */
rowMoreLinkClass: (info) => cn(
'mb-px border rounded-sm',
info.isNarrow
? 'mx-0.5 border-primary hover:bg-foreground/5'
: 'self-start mx-1 border-transparent bg-foreground/5 hover:bg-foreground/10',
),
rowMoreLinkInnerClass: (info) => cn(
info.isNarrow
? `p-px ${xxsTextClass}`
: 'p-0.5 text-xs',
),
}
export type EventCalendarViewProps =
CalendarOptions &
Required<Pick<CalendarOptions, 'popoverCloseContent'>> // ensure callers define icons
export function EventCalendarViews({
height,
views: userViews,
...restOptions
}: EventCalendarViewProps) {
return (
<FullCalendar
height={height}
/* Abstract Event
----------------------------------------------------------------------------------------- */
eventShortHeight={50}
eventColor='var(--primary)'
eventContrastColor='var(--primary-foreground)'
eventClass={(info) => cn(
info.isSelected
? ['outline-3', info.isDragging && 'shadow-lg']
: 'focus-visible:outline-3',
'outline-ring/50',
)}
/* Background Event
----------------------------------------------------------------------------------------- */
backgroundEventColor='var(--chart-3)'
backgroundEventClass='not-print:bg-[color-mix(in_oklab,var(--fc-event-color)_10%,transparent)] print:border-1 print:border-(--fc-event-color)'
backgroundEventTitleClass={(info) => cn(
'opacity-50 italic',
(info.isNarrow || info.isShort)
? `p-1 ${xxsTextClass}`
: 'p-2 text-xs',
)}
/* List-Item Event
----------------------------------------------------------------------------------------- */
listItemEventTimeClass='text-muted-foreground'
/* Block Event
----------------------------------------------------------------------------------------- */
blockEventClass={(info) => cn(
'group relative border-transparent print:border-(--fc-event-color) bg-(--fc-event-color) print:bg-white hover:bg-[color-mix(in_oklab,var(--fc-event-color)_92%,var(--fc-event-contrast-color))]',
info.isInteractive && 'active:bg-[color-mix(in_oklab,var(--fc-event-color)_85%,var(--fc-event-contrast-color))]',
(info.isDragging && !info.isSelected) && 'opacity-75',
)}
blockEventInnerClass='text-(--fc-event-contrast-color) print:text-black'
blockEventTimeClass='whitespace-nowrap overflow-hidden shrink-1'
blockEventTitleClass='whitespace-nowrap overflow-hidden shrink-100'
/* Row Event
----------------------------------------------------------------------------------------- */
rowEventClass={(info) => cn(
'mb-px border-y',
info.isStart && 'rounded-s-sm border-s',
info.isEnd && 'rounded-e-sm border-e',
)}
rowEventBeforeClass={(info) => cn(
info.isStartResizable && [
info.isSelected ? rowTouchResizerClass : rowPointerResizerClass,
'-start-1',
]
)}
rowEventAfterClass={(info) => cn(
info.isEndResizable && [
info.isSelected ? rowTouchResizerClass : rowPointerResizerClass,
'-end-1',
]
)}
rowEventInnerClass={(info) => cn(
'flex flex-row items-center',
info.isNarrow ? xxsTextClass : 'text-xs',
)}
rowEventTimeClass={(info) => cn(
info.isNarrow ? 'ps-0.5' : 'ps-1'
)}
rowEventTitleClass={(info) => cn(
info.isNarrow ? 'px-0.5' : 'px-1',
'font-medium',
)}
/* Column Event
----------------------------------------------------------------------------------------- */
columnEventClass={(info) => cn(
'border-x ring ring-background',
info.isStart && ['border-t rounded-t-lg', info.isNarrow ? 'mt-px' : 'mt-0.5'],
info.isEnd && ['border-b rounded-b-lg', info.isNarrow ? 'mb-px' : 'mb-0.5'],
)}
columnEventBeforeClass={(info) => cn(
info.isStartResizable && [
info.isSelected ? columnTouchResizerClass : columnPointerResizerClass,
'-top-1',
]
)}
columnEventAfterClass={(info) => cn(
info.isEndResizable && [
info.isSelected ? columnTouchResizerClass : columnPointerResizerClass,
'-bottom-1',
]
)}
columnEventInnerClass={(info) => cn(
'flex',
info.isShort
? 'flex-row items-center gap-1 p-1'
: ['flex-col', info.isNarrow ? 'px-1 py-0.5' : 'px-2 py-1'],
(info.isNarrow || info.isShort) ? xxsTextClass : 'text-xs',
)}
columnEventTimeClass={(info) => cn(
!info.isShort && (info.isNarrow ? 'pt-0.5' : 'pt-1')
)}
columnEventTitleClass={(info) => cn(
!info.isShort && (info.isNarrow ? 'py-0.5' : 'py-1'),
'font-medium',
)}
/* More-Link
----------------------------------------------------------------------------------------- */
moreLinkClass="focus-visible:outline-3 outline-ring/50"
moreLinkInnerClass='whitespace-nowrap overflow-hidden'
columnMoreLinkClass="my-0.5 border border-transparent print:border-black rounded-md bg-[color-mix(in_oklab,var(--foreground)_10%,var(--background))] hover:bg-[color-mix(in_oklab,var(--foreground)_13%,var(--background))] print:bg-white ring ring-background"
columnMoreLinkInnerClass={(info) => cn(
info.isNarrow
? `p-0.5 ${xxsTextClass}`
: 'p-1 text-xs',
)}
/* Day Header
----------------------------------------------------------------------------------------- */
dayHeaderClass={(info) => cn(
'justify-center',
info.inPopover ? 'border-b bg-foreground/5' :
info.isMajor && 'border border-foreground/20',
)}
dayHeaderInnerClass={(info) => cn(
'flex flex-row items-center',
info.isNarrow ? 'text-xs' : 'text-sm',
info.inPopover ? [
'm-1.5 px-1 py-0.5 rounded-sm font-semibold',
info.hasNavLink && 'hover:bg-foreground/5',
] : !info.dayNumberText ? [
'mx-0.5 my-1.5 py-0.5 px-1.5 rounded-sm text-muted-foreground',
info.hasNavLink && 'hover:bg-foreground/5',
] : !info.isToday ? [
'mx-2 my-2.5 h-6 px-1.5 rounded-sm text-muted-foreground',
info.hasNavLink && 'hover:bg-foreground/5',
] : (
'group mx-2 my-2 h-7 outline-none'
)
)}
dayHeaderContent={(info) => (
(info.inPopover || !info.dayNumberText || !info.isToday) ? (
<>{info.text}</>
) : (
<>
{info.textParts.map((textPart, i) => (
<span
key={i}
className={cn(
'whitespace-pre',
(textPart.type === 'day' && info.isToday)
? [
'first:-ms-1 last:-me-1 size-7 rounded-full font-semibold flex flex-row items-center justify-center bg-chart-1/50',
info.hasNavLink && 'group-hover:bg-chart-1/60 group-focus-visible:outline-3 outline-ring/50',
]
: 'text-muted-foreground',
)}
>{textPart.value}</span>
))}
</>
)
)}
/* Day Cell
----------------------------------------------------------------------------------------- */
dayCellClass={(info) => cn(
'border',
info.isMajor && 'border-foreground/20',
)}
dayCellTopClass={(info) => cn(
info.isNarrow ? 'min-h-0.5' : 'min-h-1',
'flex flex-row justify-end',
)}
dayCellTopInnerClass={(info) => cn(
'flex flex-row items-center',
info.isNarrow
? `my-px h-5 ${xxsTextClass}`
: 'my-1 h-6 text-sm',
!info.isToday
? [
'rounded-s-sm whitespace-nowrap',
!info.isOther && 'font-semibold',
info.isNarrow ? 'px-1' : 'px-2',
!info.monthText && 'text-muted-foreground',
info.hasNavLink && 'hover:bg-foreground/5',
]
: [
'group outline-none',
info.isNarrow
? 'mx-px'
: 'mx-2',
]
)}
dayCellTopContent={(info) => (
!info.isToday ? (
<>{info.text}</>
) : (
<>
{info.textParts.map((textPart, i) => (
<span
key={i}
className={cn(
'whitespace-pre',
(textPart.type === 'day' && info.isToday)
? [
'rounded-full font-semibold flex flex-row items-center justify-center bg-chart-1/50',
info.hasNavLink && 'group-hover:bg-chart-1/60 group-focus-visible:outline-3 outline-ring/50',
info.isNarrow
? 'size-5'
: 'size-6 first:-ms-1 last:-me-1',
]
: (!info.monthText && 'text-muted-foreground'),
)}
>{textPart.value}</span>
))}
</>
)
)}
dayCellInnerClass={(info) => cn(info.inPopover && 'p-2')}
/* Popover
----------------------------------------------------------------------------------------- */
popoverClass='border rounded-md overflow-hidden shadow-lg m-1 bg-popover text-popover-foreground min-w-55'
popoverCloseClass="group absolute top-1.5 end-1.5 p-0.5 rounded-sm hover:bg-foreground/5 focus-visible:outline-3 outline-ring/50"
/* Lane
----------------------------------------------------------------------------------------- */
dayLaneClass={(info) => cn(
'border',
info.isMajor && 'border-foreground/20',
info.isDisabled && 'bg-foreground/3',
)}
dayLaneInnerClass={(info) => (
info.isStack
? 'm-1'
: info.isNarrow ? 'mx-px' : 'mx-0.5'
)}
slotLaneClass={(info) => cn(
'border',
info.isMinor && 'border-dotted',
)}
/* List Day
----------------------------------------------------------------------------------------- */
listDayClass={(info) => cn(
'flex flex-col',
!info.isLast && 'border-b',
)}
listDayHeaderClass="-mb-px border-b bg-[color-mix(in_oklab,var(--foreground)_3%,var(--background))] flex flex-row items-center justify-between"
listDayHeaderInnerClass={(info) => cn(
'm-1.5 px-1.5 py-0.5 rounded-sm text-sm',
!info.level && 'font-semibold',
(!info.level && info.isToday)
? [
'bg-chart-1/50',
info.hasNavLink && 'hover:bg-chart-1/60 focus-visible:outline-3 outline-ring/50',
]
: info.hasNavLink && 'hover:bg-foreground/5',
)}
listDayBodyClass='mt-px px-1.5 py-2 gap-2'
/* Single Month (in Multi-Month)
----------------------------------------------------------------------------------------- */
singleMonthClass={(info) => cn(
info.multiMonthColumns > 1 && 'm-3',
(info.multiMonthColumns === 1 && !info.isLast) && 'border-b',
)}
singleMonthHeaderClass={(info) => cn(
info.multiMonthColumns > 1 ? 'pb-2' : 'py-1 border-b bg-background',
'items-center',
)}
singleMonthHeaderInnerClass={(info) => cn(
'px-1.5 py-0.5 rounded-sm text-base font-semibold',
info.hasNavLink && 'hover:bg-foreground/5',
)}
/* Misc Table
----------------------------------------------------------------------------------------- */
tableBodyClass='bg-background'
fillerClass='border opacity-50'
dayNarrowWidth={100}
dayHeaderRowClass='border'
dayRowClass='border'
slotHeaderRowClass='border'
slotHeaderInnerClass='text-muted-foreground'
/* Misc Content
----------------------------------------------------------------------------------------- */
navLinkClass="focus-visible:outline-3 outline-ring/50"
inlineWeekNumberClass={(info) => cn(
'absolute start-0 whitespace-nowrap rounded-e-sm text-muted-foreground',
info.isNarrow
? `top-0.5 my-px p-0.5 ${xxsTextClass}`
: 'top-1 p-1 text-xs',
info.hasNavLink && 'hover:bg-foreground/5',
)}
highlightClass='bg-chart-1/20'
nonBusinessHoursClass='bg-foreground/3'
nowIndicatorLineClass='-m-px border-1 border-destructive'
nowIndicatorDotClass="-m-[6px] border-6 border-destructive size-0 rounded-full ring-2 ring-background"
/* View-Specific Options
----------------------------------------------------------------------------------------- */
views={{
...userViews,
dayGrid: {
...dayRowCommonClasses,
tableHeaderClass: 'bg-background',
dayHeaderAlign: (info) => info.inPopover ? 'start' : info.isNarrow ? 'center' : 'end',
dayHeaderDividerClass: 'border-b',
dayCellBottomClass: getShortDayCellBottomClass,
...userViews?.dayGrid,
},
multiMonth: {
...dayRowCommonClasses,
className: 'bg-foreground/3',
tableHeaderClass: (info) => cn(info.multiMonthColumns === 1 && 'bg-background'),
dayHeaderAlign: (info) => info.inPopover ? 'start' : info.isNarrow ? 'center' : 'end',
dayHeaderDividerClass: (info) => cn(info.multiMonthColumns === 1 && 'border-b'),
dayCellBottomClass: getShortDayCellBottomClass,
tableBodyClass: (info) => cn(info.multiMonthColumns > 1 && 'border rounded-sm overflow-hidden'),
...userViews?.multiMonth,
},
timeGrid: {
...dayRowCommonClasses,
tableHeaderClass: 'bg-background',
dayHeaderAlign: (info) => info.inPopover ? 'start' : 'center',
dayHeaderDividerClass: (info) => cn(
'border-b',
!info.options.allDaySlot && 'border-foreground/20 not-print:shadow-sm',
),
dayCellBottomClass: tallDayCellBottomClass,
/* TimeGrid > Week Number Header
------------------------------------------------------------------------------------- */
weekNumberHeaderClass: 'items-center justify-end',
weekNumberHeaderInnerClass: (info) => cn(
'mx-0.5 h-6 px-1.5 text-muted-foreground flex flex-row items-center rounded-sm',
info.isNarrow ? 'text-xs' : 'text-sm',
info.hasNavLink && 'hover:bg-foreground/5',
),
/* TimeGrid > All-Day Header
------------------------------------------------------------------------------------- */
allDayHeaderClass: 'items-center',
allDayHeaderInnerClass: (info) => cn(
'm-2 text-muted-foreground',
info.isNarrow ? xxsTextClass : 'text-xs',
),
allDayDividerClass: 'border-b border-foreground/20 not-print:shadow-sm',
/* TimeGrid > Slot Header
------------------------------------------------------------------------------------- */
slotHeaderClass: 'justify-end',
slotHeaderInnerClass: (info) => cn(
'relative m-2',
info.isNarrow
? `-top-3.5 ${xxsTextClass}`
: '-top-4 text-xs',
info.isFirst && 'hidden',
),
slotHeaderDividerClass: 'border-e',
...userViews?.timeGrid,
},
list: {
/* List-View > List-Item Event
------------------------------------------------------------------------------------- */
listItemEventClass: (info) => cn(
'group py-1 rounded-sm hover:bg-muted/50',
info.isInteractive && 'focus-visible:bg-muted/50 -outline-offset-3',
),
listItemEventBeforeClass: 'w-1.5 bg-(--fc-event-color) rounded-full',
listItemEventInnerClass: '[display:contents]',
listItemEventTimeClass: "-order-1 shrink-0 w-1/2 max-w-60 px-4 py-2 whitespace-nowrap overflow-hidden text-ellipsis text-sm",
listItemEventTitleClass: (info) => cn(
'grow min-w-0 px-4 py-2 whitespace-nowrap overflow-hidden text-sm',
info.event.url && 'group-hover:underline',
),
/* No-Events Screen
------------------------------------------------------------------------------------- */
noEventsClass: 'grow flex flex-col items-center justify-center',
noEventsInnerClass: 'py-15 text-muted-foreground',
...userViews?.list,
},
}}
{...restOptions}
/>
)
}
+62
View File
@@ -0,0 +1,62 @@
"use client";
import * as React from "react";
import { CheckIcon, CopyIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { cn, copyText } from "@/lib/utils";
type CopyButtonProps = {
// 要复制的文本;为空则按钮禁用。
text: string | null | undefined;
// 复制成功后的 toast 文案,默认「已复制」。
successMessage?: string;
label?: React.ReactNode;
size?: React.ComponentProps<typeof Button>["size"];
variant?: React.ComponentProps<typeof Button>["variant"];
className?: string;
};
// CopyButton 统一的「复制到剪贴板」按钮:内置成功/失败反馈,并在 HTTP 非安全上下文
// 下自动降级(见 copyText)。
export function CopyButton({
text,
successMessage,
label,
size = "sm",
variant = "outline",
className,
}: CopyButtonProps) {
const t = useTranslations("copyButton");
const [copied, setCopied] = React.useState(false);
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(() => {
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, []);
async function handleCopy() {
if (!text) return;
const ok = await copyText(text);
if (ok) {
setCopied(true);
toast.success(successMessage ?? t("success"));
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 1500);
} else {
toast.error(t("error"));
}
}
return (
<Button type="button" size={size} variant={variant} className={cn(className)} disabled={!text} onClick={handleCopy}>
{copied ? <CheckIcon /> : <CopyIcon />}
{label ?? t("label")}
</Button>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import * as React from "react";
import { format, subDays } from "date-fns";
import type { DateRange } from "react-day-picker";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
interface DateRangePickerProps {
value?: DateRange;
onChange?: (value: DateRange | undefined) => void;
}
export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
const [open, setOpen] = React.useState(false);
const [internalDateRange, setInternalDateRange] = React.useState<DateRange | undefined>(() => {
const to = new Date();
const from = subDays(to, 29);
return { from, to };
});
const dateRange = value ?? internalDateRange;
let dateRangeLabel = "Select date";
if (dateRange?.from) {
dateRangeLabel = format(dateRange.from, "d MMM yyyy");
}
if (dateRange?.from && dateRange.to) {
dateRangeLabel = `${format(dateRange.from, "d MMM yyyy")} - ${format(dateRange.to, "d MMM yyyy")}`;
}
const handleDateChange = (nextValue: DateRange | undefined) => {
if (!value) {
setInternalDateRange(nextValue);
}
onChange?.(nextValue);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" id="date" className="font-normal">
{dateRangeLabel}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="end">
<Calendar
mode="range"
defaultMonth={dateRange?.from}
selected={dateRange}
onSelect={handleDateChange}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
);
}
Binary file not shown.
@@ -0,0 +1,93 @@
"use client";
import * as React from "react";
import { RotateCcwIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import type { FindingRetest } from "@/lib/types";
interface FindingRetestDialogProps {
findingId: string;
findingName?: string;
onClose: () => void;
onStarted?: (retest: FindingRetest) => void;
}
// 仅在打开时挂载,关闭后清空说明;列表与详情共用提交锁及错误处理,启动后留在当前页。
export function FindingRetestDialog({ findingId, findingName, onClose, onStarted }: FindingRetestDialogProps) {
const t = useTranslations("retest.dialog");
const notesId = React.useId();
const [notes, setNotes] = React.useState("");
const [submitting, setSubmitting] = React.useState(false);
const submitLock = React.useRef(false);
async function start() {
if (submitLock.current) return;
submitLock.current = true;
setSubmitting(true);
try {
const result = await api.startFindingRetest(findingId, notes.trim());
onStarted?.(result.retest);
onClose();
toast.success(result.created ? t("startedNew") : t("alreadyRunning"));
} catch (e) {
toast.error(t("startFailed", { message: (e as Error).message }));
} finally {
submitLock.current = false;
setSubmitting(false);
}
}
return (
<Dialog open onOpenChange={(open) => !open && !submitLock.current && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("title", { id: findingId })}</DialogTitle>
<DialogDescription className="break-words">
{findingName ? <span className="mb-2 block">{findingName}</span> : null}
{t("desc")}
</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-disabled={submitting}>
<FieldLabel htmlFor={notesId}>{t("notesLabel")}</FieldLabel>
<Textarea
id={notesId}
value={notes}
maxLength={4000}
rows={4}
disabled={submitting}
onChange={(e) => setNotes(e.target.value)}
placeholder={t("notesPlaceholder")}
/>
<FieldDescription>{t("notesDesc")}</FieldDescription>
</Field>
</FieldGroup>
<DialogFooter>
<Button variant="outline" disabled={submitting} onClick={onClose}>
{t("cancel")}
</Button>
<Button disabled={submitting} onClick={() => void start()}>
{submitting ? <Spinner data-icon="inline-start" /> : <RotateCcwIcon data-icon="inline-start" />}
{submitting ? t("submitting") : t("submit")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+180
View File
@@ -0,0 +1,180 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { RotateCcwIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { FindingRetestDialog } from "@/components/finding-retest-dialog";
import { Markdown } from "@/components/markdown";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { api } from "@/lib/api";
import type { FindingRetest } from "@/lib/types";
function active(r: FindingRetest) {
return r.status === "pending" || r.status === "running";
}
export function FindingRetestPanel({
findingId,
findingName,
readOnly = false,
onCompleted,
}: {
findingId: string;
findingName?: string;
readOnly?: boolean;
onCompleted?: () => void;
}) {
const t = useTranslations("retest.panel");
const [items, setItems] = React.useState<FindingRetest[] | null>(null);
const [error, setError] = React.useState("");
const [open, setOpen] = React.useState(false);
const requestSeq = React.useRef(0);
const previousItems = React.useRef<FindingRetest[]>([]);
const load = React.useCallback(async () => {
const seq = ++requestSeq.current;
try {
const rows = await api.findingRetests(findingId);
if (seq !== requestSeq.current) return;
const completed = previousItems.current.some(
(item) => active(item) && rows.some((row) => row.id === item.id && !active(row)),
);
previousItems.current = rows;
setItems(rows);
setError("");
if (completed) onCompleted?.();
} catch (e) {
if (seq === requestSeq.current) setError((e as Error).message);
}
}, [findingId, onCompleted]);
React.useEffect(() => {
void load();
return () => {
requestSeq.current++;
};
}, [load]);
React.useEffect(() => {
if (error || !items?.some(active)) return;
const timer = setTimeout(() => void load(), 3000);
return () => clearTimeout(timer);
}, [error, items, load]);
const running = items?.find(active);
return (
<Card>
<CardHeader className="flex flex-row flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-1.5">
<CardTitle>{t("title")}</CardTitle>
<CardDescription>{t("desc")}</CardDescription>
</div>
{running?.conversation_id != null ? (
<Button asChild variant="outline" size="sm">
<Link href={`/chat?c=${running.conversation_id}`} title={t("viewRunning")}>
<Spinner data-icon="inline-start" aria-hidden="true" />
{t("status.running")}
</Link>
</Button>
) : null}
{!running && !readOnly ? (
<Button size="sm" onClick={() => setOpen(true)} disabled={items === null || !!error}>
<RotateCcwIcon data-icon="inline-start" />
{t("start")}
</Button>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-3">
{error ? (
<Alert variant="destructive">
<AlertDescription>
{t("loadError", { error })}
<Button variant="outline" size="sm" onClick={() => void load()}>
{t("retry")}
</Button>
</AlertDescription>
</Alert>
) : null}
{!error && items === null ? <Skeleton className="h-16 w-full" /> : null}
{!error && items?.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyTitle>{t("emptyTitle")}</EmptyTitle>
<EmptyDescription>{t("emptyDesc")}</EmptyDescription>
</EmptyHeader>
</Empty>
) : null}
{!error && items
? items.map((item) => (
<div key={item.id} className="flex min-w-0 flex-col gap-2 rounded-lg border p-3">
<div className="flex flex-wrap items-center gap-2">
<Badge
variant={item.status === "completed" && item.verdict === "reproduced" ? "destructive" : "secondary"}
>
{item.status === "completed" && item.verdict
? t(`verdict.${item.verdict}`)
: t(`status.${item.status}`)}
</Badge>
<span className="text-muted-foreground text-xs">
#{item.id} · {new Date(item.created_at).toLocaleString("ko-KR")}
</span>
{item.conversation_id != null ? (
<Button asChild variant="ghost" size="sm" className="ml-auto">
<Link href={`/chat?c=${item.conversation_id}`}>{t("viewSession")}</Link>
</Button>
) : (
<span className="text-muted-foreground text-xs">{t("sessionDeleted")}</span>
)}
</div>
{item.status === "completed" && item.summary ? (
<p className="whitespace-pre-wrap break-words text-sm">{item.summary}</p>
) : null}
{item.error ? (
<p className="whitespace-pre-wrap break-words text-destructive text-sm">{item.error}</p>
) : null}
{item.notes ? (
<p className="whitespace-pre-wrap break-words text-muted-foreground text-xs">
{t("notes", { notes: item.notes })}
</p>
) : null}
{item.status === "completed" && item.evidence ? (
<details className="min-w-0">
<summary className="cursor-pointer text-sm">{t("evidence")}</summary>
<div className="mt-3 overflow-x-auto">
<Markdown text={item.evidence} />
</div>
</details>
) : null}
</div>
))
: null}
</CardContent>
{open ? (
<FindingRetestDialog
findingId={findingId}
findingName={findingName}
onClose={() => setOpen(false)}
onStarted={(retest) => {
requestSeq.current++;
const rows = [retest, ...previousItems.current.filter((item) => item.id !== retest.id)];
previousItems.current = rows;
setItems(rows);
setError("");
void load();
}}
/>
) : null}
</Card>
);
}
@@ -0,0 +1,285 @@
"use client";
import * as React from "react";
import { ArrowDownIcon, ArrowUpIcon, PlusIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { TrafficEvidenceViewer } from "@/components/traffic-evidence-viewer";
import { TrafficPickerDialog } from "@/components/traffic-picker-dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import type { FindingTraffic, FindingTrafficBinding, TrafficEvidenceRole } from "@/lib/types";
// Role values in display order; labels resolve via t(`traffic.role.${value}`) at render time.
const ROLE_VALUES: TrafficEvidenceRole[] = ["baseline", "proof", "verification", "supporting"];
export function FindingTrafficPanel({
findingId,
contextTask,
readOnly,
onChanged,
}: {
findingId: string;
contextTask?: string;
readOnly?: boolean;
onChanged: () => void;
}) {
const t = useTranslations("findings");
const [data, setData] = React.useState<FindingTraffic | null>(null);
const [error, setError] = React.useState("");
const [adding, setAdding] = React.useState(false);
const [preview, setPreview] = React.useState<string | null>(null);
const [editing, setEditing] = React.useState<FindingTrafficBinding | null>(null);
const [role, setRole] = React.useState<TrafficEvidenceRole>("supporting");
const [note, setNote] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [reload, setReload] = React.useState(0);
// biome-ignore lint/correctness/useExhaustiveDependencies: retry token intentionally refreshes this request.
React.useEffect(() => {
let active = true;
setData(null);
setError("");
api
.findingTraffic(findingId, contextTask)
.then((value) => {
if (active) setData(value);
})
.catch((e: Error) => {
if (active) setError(e.message);
});
return () => {
active = false;
};
}, [findingId, contextTask, reload]);
async function mutate(action: () => Promise<FindingTraffic>) {
setBusy(true);
try {
setData(await action());
setEditing(null);
onChanged();
toast.success(t("traffic.updated"));
} catch (e) {
toast.error((e as Error).message);
setReload((n) => n + 1);
} finally {
setBusy(false);
}
}
function move(index: number, delta: number) {
if (!data) return;
const ids = data.bindings.map((b) => b.id);
[ids[index], ids[index + delta]] = [ids[index + delta], ids[index]];
void mutate(() => api.orderFindingTraffic(findingId, ids, data.version, contextTask));
}
return (
<>
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle>
{t("traffic.title")} {data ? `(${data.bindings.length})` : ""}
</CardTitle>
{!readOnly ? (
<Button variant="outline" size="sm" disabled={!data || busy} onClick={() => setAdding(true)}>
<PlusIcon data-icon="inline-start" />
{t("traffic.bind")}
</Button>
) : (
<Badge variant="outline">{t("traffic.inherited")}</Badge>
)}
</div>
<CardDescription>{t("traffic.description")}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{error ? (
<Alert variant="destructive">
<AlertDescription>
{error}
<Button variant="link" onClick={() => setReload((n) => n + 1)}>
{t("traffic.retry")}
</Button>
</AlertDescription>
</Alert>
) : null}
{!data ? (
<Skeleton className="h-28 w-full" />
) : data.bindings.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyTitle>{t("traffic.empty")}</EmptyTitle>
<EmptyDescription>{t("traffic.emptyHint")}</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
data.bindings.map((b, index) => (
<div key={b.id} className="flex min-w-0 flex-col gap-2 rounded-md border p-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">
{index + 1}. {t(`traffic.role.${b.role}`)}
</Badge>
<Badge variant="secondary">
{b.snapshot.method} · {b.snapshot.status}
</Badge>
<span className="text-xs text-muted-foreground">{t("traffic.evidenceNo", { id: b.id })}</span>
</div>
<Button
variant="link"
className="h-auto justify-start p-0 text-left whitespace-normal"
onClick={() => setPreview(b.id)}
>
<span className="break-all font-mono text-xs">{b.snapshot.url}</span>
</Button>
{b.note ? <p className="text-sm whitespace-pre-wrap">{b.note}</p> : null}
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
{new Date(b.snapshot.captured_at * 1000).toLocaleString("ko-KR")}
</span>
<div className="flex flex-wrap gap-1">
<Button variant="outline" size="sm" onClick={() => setPreview(b.id)}>
{t("traffic.viewRaw")}
</Button>
{!readOnly ? (
<>
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => {
setEditing(b);
setRole(b.role);
setNote(b.note);
}}
>
{t("traffic.editNote")}
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={t("traffic.moveUp", { id: b.id })}
disabled={busy || index === 0}
onClick={() => move(index, -1)}
>
<ArrowUpIcon />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={t("traffic.moveDown", { id: b.id })}
disabled={busy || index === data.bindings.length - 1}
onClick={() => move(index, 1)}
>
<ArrowDownIcon />
</Button>
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() =>
void mutate(() => api.removeFindingTraffic(findingId, b.id, data.version, contextTask))
}
>
{t("traffic.unbind")}
</Button>
</>
) : null}
</div>
</div>
</div>
))
)}
</CardContent>
</Card>
{adding && data ? (
<TrafficPickerDialog
findingId={findingId}
contextTask={contextTask}
bound={data.bindings}
onClose={() => setAdding(false)}
onBound={() => {
setReload((n) => n + 1);
onChanged();
}}
/>
) : null}
<TrafficEvidenceViewer
findingId={findingId}
bindingId={preview}
contextTask={contextTask}
onClose={() => setPreview(null)}
/>
<Dialog
open={editing !== null}
onOpenChange={(open) => {
if (!open && !busy) setEditing(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("traffic.editTitle")}</DialogTitle>
<DialogDescription>{t("traffic.editDescription")}</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor="evidence-role">{t("traffic.roleLabel")}</FieldLabel>
<Select value={role} onValueChange={(v) => setRole(v as TrafficEvidenceRole)}>
<SelectTrigger id="evidence-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{ROLE_VALUES.map((value) => (
<SelectItem key={value} value={value}>
{t(`traffic.role.${value}`)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="evidence-note">{t("traffic.noteLabel")}</FieldLabel>
<Textarea id="evidence-note" value={note} onChange={(e) => setNote(e.target.value)} />
</Field>
</FieldGroup>
<DialogFooter>
<Button variant="outline" disabled={busy} onClick={() => setEditing(null)}>
{t("common.cancel")}
</Button>
<Button
disabled={busy || !editing || !data}
onClick={() => {
if (editing && data)
void mutate(() =>
api.editFindingTraffic(findingId, editing.id, data.version, { role, note }, contextTask),
);
}}
>
{t("traffic.saveNote")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+218
View File
@@ -0,0 +1,218 @@
"use client";
import * as React from "react";
import { CheckIcon, CopyIcon, WrapTextIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn, copyText } from "@/lib/utils";
function statusTone(status: number) {
if (status >= 500) return "text-red-500";
if (status >= 400) return "text-amber-500";
if (status >= 300) return "text-blue-500";
if (status >= 200) return "text-emerald-500";
return "text-muted-foreground";
}
function StartLine({ line }: { line: string }) {
const response = /^(HTTP\/\S+)(\s+)(\d{3})(.*)$/.exec(line);
if (response) {
return (
<>
<span className="text-muted-foreground">{response[1]}</span>
{response[2]}
<span className={statusTone(Number(response[3]))}>{response[3]}</span>
{response[4]}
</>
);
}
const request = /^([A-Z]+)(\s+)(\S+)(\s+)(HTTP\/\S+)$/.exec(line);
if (request) {
return (
<>
<span className="font-semibold text-primary">{request[1]}</span>
{request[2]}
<span className="text-chart-2">{request[3]}</span>
{request[4]}
<span className="text-muted-foreground">{request[5]}</span>
</>
);
}
return line;
}
function HeaderLine({ line }: { line: string }) {
const separator = line.indexOf(":");
if (separator <= 0) return line;
return (
<>
<span className="text-primary">{line.slice(0, separator)}</span>
<span className="text-muted-foreground">:</span>
<span className="text-chart-2">{line.slice(separator + 1)}</span>
</>
);
}
function JsonBody({ body }: { body: string }) {
const parts: React.ReactNode[] = [];
const tokens = /("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g;
let cursor = 0;
for (const match of body.matchAll(tokens)) {
const index = match.index ?? 0;
if (index > cursor) parts.push(body.slice(cursor, index));
let className = "text-chart-4";
if (match[1]) className = match[2] ? "text-primary" : "text-chart-2";
else if (match[3]) className = "text-chart-3";
parts.push(
<span key={`${index}-${match[0].length}`} className={className}>
{match[0]}
</span>,
);
cursor = index + match[0].length;
}
if (cursor < body.length) parts.push(body.slice(cursor));
return parts;
}
function MarkupBody({ body }: { body: string }) {
const parts: React.ReactNode[] = [];
const tags = /<\/?[A-Za-z][^>]*>|<!--[\s\S]*?-->/g;
let cursor = 0;
for (const match of body.matchAll(tags)) {
const index = match.index ?? 0;
if (index > cursor) parts.push(body.slice(cursor, index));
parts.push(
<span key={`${index}-${match[0].length}`} className="text-primary">
{match[0]}
</span>,
);
cursor = index + match[0].length;
}
if (cursor < body.length) parts.push(body.slice(cursor));
return parts;
}
type BodyFormat = "json" | "markup" | "plain";
function detectBodyFormat(body: string): BodyFormat {
const trimmed = body.trimStart();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) return "json";
if (trimmed.startsWith("<")) return "markup";
return "plain";
}
function HighlightedBody({ body, format }: { body: string; format: BodyFormat }) {
if (format === "json") return <JsonBody body={body} />;
if (format === "markup") return <MarkupBody body={body} />;
return body;
}
export function HttpCodeBlock({ raw }: { raw: string }) {
const t = useTranslations("trafficEvidence.code");
const [wrapLines, setWrapLines] = React.useState(true);
const [copied, setCopied] = React.useState(false);
const value = raw || t("empty");
const lines = value.replaceAll("\r\n", "\n").split("\n");
const separator = lines.indexOf("");
const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : "";
const bodyFormat = detectBodyFormat(body);
React.useEffect(() => {
if (!copied) return;
const timer = window.setTimeout(() => setCopied(false), 1500);
return () => window.clearTimeout(timer);
}, [copied]);
const copyPacket = async () => {
const ok = await copyText(value);
if (ok) {
setCopied(true);
return;
}
toast.error(t("copyFailed"));
};
const renderLine = (line: string, index: number) => {
if (index === 0) return <StartLine line={line} />;
if (separator < 0 || index < separator) return <HeaderLine line={line} />;
if (index === separator) return null;
return <HighlightedBody body={line} format={bodyFormat} />;
};
return (
<div className="relative mx-3 my-4 overflow-hidden rounded-md border bg-background shadow-xs">
<div className="absolute top-2 right-2 flex items-center gap-0.5 rounded-md border bg-background/95 p-0.5 shadow-xs">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={wrapLines ? t("wrapOff") : t("wrapOn")}
aria-pressed={wrapLines}
onClick={() => setWrapLines((current) => !current)}
>
<WrapTextIcon />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{wrapLines ? t("wrapOff") : t("wrapOn")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={copied ? t("copiedPacket") : t("copyPacket")}
onClick={() => void copyPacket()}
>
{copied ? <CheckIcon /> : <CopyIcon />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{copied ? t("copied") : t("copyPacket")}</TooltipContent>
</Tooltip>
</div>
{/* biome-ignore lint/a11y/useSemanticElements: textarea cannot preserve line numbers and syntax-highlighting markup. */}
<div
role="textbox"
aria-label={t("label")}
aria-multiline="true"
aria-readonly="true"
tabIndex={0}
className="max-h-[calc(100vh-15rem)] min-w-0 overflow-auto bg-background py-3 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
onKeyDown={(event) => {
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "a") return;
event.preventDefault();
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
range.selectNodeContents(event.currentTarget);
selection.removeAllRanges();
selection.addRange(range);
}}
>
{lines.map((line, index) => (
<div
key={`${index}-${line}`}
data-line={index + 1}
className="grid min-w-0 grid-cols-[1.5rem_minmax(0,1fr)] leading-relaxed before:sticky before:left-0 before:self-stretch before:border-r before:bg-muted/20 before:px-1 before:text-right before:text-muted-foreground/60 before:content-[attr(data-line)]"
>
<code
className={cn(
"min-h-[1lh] min-w-0 pr-16 pl-1.5 [tab-size:4]",
wrapLines ? "break-words whitespace-pre-wrap" : "whitespace-pre",
)}
>
{renderLine(line, index)}
</code>
</div>
))}
</div>
</div>
);
}
+163
View File
@@ -0,0 +1,163 @@
"use client";
import * as React from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { api } from "@/lib/api";
import type { Finding, FindingsPage } from "@/lib/types";
export function LinkTrafficDialog({
trafficIds,
onClose,
onBound,
}: {
trafficIds: string[];
onClose: () => void;
onBound: () => void;
}) {
const t = useTranslations("linkTraffic");
const [query, setQuery] = React.useState("");
const [page, setPage] = React.useState(1);
const [data, setData] = React.useState<FindingsPage | null>(null);
const [selected, setSelected] = React.useState<Finding | null>(null);
const [error, setError] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
let active = true;
setLoading(true);
setError("");
const timer = setTimeout(() => {
api
.findingsPage({ page, pageSize: 20, query })
.then((d) => {
if (active) setData(d);
})
.catch((e: Error) => {
if (active) setError(e.message);
})
.finally(() => {
if (active) setLoading(false);
});
}, 250);
return () => {
active = false;
clearTimeout(timer);
};
}, [query, page]);
async function save() {
if (!selected?.finding_id) return;
setBusy(true);
setError("");
try {
await api.bindFindingTraffic(
selected.finding_id,
trafficIds.map((traffic_id) => ({ traffic_id })),
);
toast.success(t("boundToast", { count: trafficIds.length, id: selected.finding_id }));
onBound();
onClose();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<Dialog
open
onOpenChange={(open) => {
if (!open && !busy) onClose();
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("title")}</DialogTitle>
<DialogDescription>{t("description", { count: trafficIds.length })}</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor="link-finding-query">{t("findLabel")}</FieldLabel>
<Input
id="link-finding-query"
placeholder={t("searchPlaceholder")}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPage(1);
}}
/>
</Field>
</FieldGroup>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<div className="flex max-h-[40vh] flex-col gap-2 overflow-auto" aria-busy={loading}>
{(data?.items ?? []).map((f) => (
<Button
key={f.finding_id ?? f.id}
variant={selected?.finding_id === f.finding_id ? "secondary" : "outline"}
className="h-auto justify-start p-3 text-left whitespace-normal"
aria-pressed={selected?.finding_id === f.finding_id}
disabled={busy || !f.finding_id || f.inherited}
onClick={() => setSelected(f)}
>
<span className="flex min-w-0 flex-col gap-1">
<span>
#{f.finding_id ?? f.id} · {f.name || f.vulnclass}
</span>
<span className="line-clamp-2 text-xs text-muted-foreground">{f.summary}</span>
</span>
</Button>
))}
{!data?.items.length ? (
<p className="py-6 text-center text-sm text-muted-foreground">{loading ? t("loading") : t("empty")}</p>
) : null}
</div>
<div className="flex items-center justify-between gap-2">
<Button variant="outline" size="sm" disabled={loading || page <= 1} onClick={() => setPage((p) => p - 1)}>
{t("prevPage")}
</Button>
<span className="text-xs">{t("pageInfo", { page, total: data?.total ?? 0 })}</span>
<Button
variant="outline"
size="sm"
disabled={loading || page * 20 >= (data?.total ?? 0)}
onClick={() => setPage((p) => p + 1)}
>
{t("nextPage")}
</Button>
</div>
<p className="text-sm">
{selected
? t("selected", { id: selected.finding_id ?? "", name: selected.name || selected.vulnclass })
: t("selectPrompt")}
</p>
<DialogFooter>
<Button variant="outline" disabled={busy} onClick={onClose}>
{t("cancel")}
</Button>
<Button disabled={busy || !selected} onClick={() => void save()}>
{busy ? t("saving") : t("confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import * as React from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
// Tailwind-styled element overrides (no typography plugin in this project, so we
// style each element). `node` is stripped — it's not a valid DOM attribute.
const components: Components = {
h1: ({ node, ...p }) => <div className="mt-2.5 text-base font-semibold" {...p} />,
h2: ({ node, ...p }) => <div className="mt-2 text-sm font-semibold" {...p} />,
h3: ({ node, ...p }) => <div className="mt-1.5 text-sm font-medium text-foreground/90" {...p} />,
h4: ({ node, ...p }) => <div className="mt-1.5 text-sm font-medium text-foreground/90" {...p} />,
p: ({ node, ...p }) => <p className="leading-relaxed" {...p} />,
ul: ({ node, ...p }) => <ul className="my-1 list-disc space-y-0.5 pl-5" {...p} />,
ol: ({ node, ...p }) => <ol className="my-1 list-decimal space-y-0.5 pl-5" {...p} />,
strong: ({ node, ...p }) => <strong className="font-semibold" {...p} />,
a: ({ node, ...p }) => (
<a className="text-primary underline underline-offset-2" target="_blank" rel="noreferrer" {...p} />
),
hr: () => <hr className="my-2 border-border" />,
blockquote: ({ node, ...p }) => (
<blockquote className="my-1 border-l-2 border-border pl-2.5 text-muted-foreground" {...p} />
),
pre: ({ node, ...p }) => (
<pre className="my-1.5 overflow-auto rounded-md bg-muted/70 p-2.5 text-xs leading-relaxed" {...p} />
),
code: ({ node, className, children, ...rest }) => {
const block = /language-/.test(className || "");
return block ? (
<code className={"font-mono text-xs " + (className || "")} {...rest}>
{children}
</code>
) : (
<code className="break-all rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]" {...rest}>
{children}
</code>
);
},
table: ({ node, ...p }) => (
<div className="my-1.5 overflow-x-auto">
<table className="w-full border-collapse text-xs" {...p} />
</div>
),
th: ({ node, ...p }) => <th className="border border-border bg-muted/60 px-2 py-1 text-left font-medium" {...p} />,
td: ({ node, ...p }) => <td className="border border-border px-2 py-1 align-top" {...p} />,
};
export function Markdown({ text }: { text: string }) {
return (
<div className="min-w-0 space-y-1.5 text-sm [&>*:first-child]:mt-0">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{text}
</ReactMarkdown>
</div>
);
}
+355
View File
@@ -0,0 +1,355 @@
"use client";
import * as React from "react";
import { AtSignIcon, ChevronRightIcon, Loader2Icon, XIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge";
import { InputGroupTextarea } from "@/components/ui/input-group";
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import { activeMention, type ChatMention, mentionSearch, mentionToken, selectedMentions } from "@/lib/chat-mentions";
import { cn } from "@/lib/utils";
type Props = Omit<React.ComponentProps<"textarea">, "value" | "onChange" | "ref"> & {
value: string;
onValueChange: (value: string) => void;
inputGroup?: boolean;
};
export function MentionTextarea({ value, onValueChange, onKeyDown, className, inputGroup, disabled, ...props }: Props) {
const t = useTranslations("mentionTextarea");
const textarea = React.useRef<HTMLTextAreaElement>(null);
const composing = React.useRef(false);
const pendingKeyboardIndex = React.useRef<number | null>(null);
const [cursor, setCursor] = React.useState<number | null>(null);
const [dismissed, setDismissed] = React.useState(false);
const [activeIndex, setActiveIndex] = React.useState(0);
const [result, setResult] = React.useState<{
key: string;
items: ChatMention[];
next_cursor?: string;
error?: string;
loadingMore?: boolean;
pageError?: string;
} | null>(null);
const request = React.useRef<{ key: string; controller: AbortController; loadingMore: boolean } | null>(null);
const listId = React.useId();
const active = cursor === null ? null : activeMention(value, cursor);
const search = mentionSearch(active?.query ?? "");
const open = !!active && !dismissed && !disabled;
const categories = search.categories;
const requestKey = open && !categories.length ? `${search.kind}:${search.query}` : "";
const loading = !!requestKey && result?.key !== requestKey;
const items = result?.key === requestKey ? result.items : [];
const error = result?.key === requestKey ? result.error : undefined;
const nextCursor = result?.key === requestKey ? result.next_cursor : undefined;
const loadingMore = result?.key === requestKey && result.loadingMore;
const pageError = result?.key === requestKey ? result.pageError : undefined;
const count = categories.length || items.length;
const selected = selectedMentions(value);
React.useEffect(() => {
if (!requestKey) return;
setResult(null);
const controller = new AbortController();
request.current = { key: requestKey, controller, loadingMore: false };
const timer = setTimeout(() => {
api.chatMentions(search.kind, search.query, controller.signal).then(
(page) => {
if (!controller.signal.aborted) setResult({ key: requestKey, ...page });
},
(error: Error) => {
if (!controller.signal.aborted) setResult({ key: requestKey, items: [], error: error.message });
},
);
}, 180);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [requestKey, search.kind, search.query]);
function loadMore() {
const session = request.current;
if (
!nextCursor ||
!session ||
session.key !== requestKey ||
session.controller.signal.aborted ||
session.loadingMore
)
return;
session.loadingMore = true;
setResult((current) =>
current?.key === requestKey ? { ...current, loadingMore: true, pageError: undefined } : current,
);
api
.chatMentions(search.kind, search.query, session.controller.signal, nextCursor)
.then(
(page) => {
if (session.controller.signal.aborted) return;
setResult((current) => {
if (current?.key !== session.key) return current;
const seen = new Set(current.items.map((item) => `${item.kind}:${item.id}`));
return {
...current,
items: [...current.items, ...page.items.filter((item) => !seen.has(`${item.kind}:${item.id}`))],
next_cursor: page.next_cursor,
loadingMore: false,
};
});
},
(error: Error) => {
if (!session.controller.signal.aborted)
setResult((current) =>
current?.key === session.key ? { ...current, loadingMore: false, pageError: error.message } : current,
);
},
)
.finally(() => {
session.loadingMore = false;
});
}
React.useEffect(() => {
if (open) document.getElementById(`${listId}-${activeIndex}`)?.scrollIntoView({ block: "nearest" });
}, [activeIndex, listId, open]);
React.useEffect(() => {
if (open && pendingKeyboardIndex.current !== null && pendingKeyboardIndex.current < count) {
document.getElementById(`${listId}-${pendingKeyboardIndex.current}`)?.scrollIntoView({ block: "nearest" });
pendingKeyboardIndex.current = null;
}
}, [count, listId, open]);
function replaceActive(text: string, close: boolean) {
if (!active) return;
const next = value.slice(0, active.start) + text + value.slice(active.end);
const caret = active.start + text.length;
onValueChange(next);
setCursor(caret);
setDismissed(close);
setActiveIndex(0);
requestAnimationFrame(() => {
textarea.current?.focus();
textarea.current?.setSelectionRange(caret, caret);
});
}
function choose(index: number) {
if (categories[index]) replaceActive(`@${categories[index].label} `, false);
else if (items[index]) replaceActive(`${mentionToken(items[index])} `, true);
}
function keyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
if (composing.current || event.nativeEvent.isComposing || event.keyCode === 229) return;
if (open) {
if (event.key === "Escape") {
event.preventDefault();
setDismissed(true);
return;
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (event.key === "ArrowDown" && count && activeIndex >= count - 1 && nextCursor) {
pendingKeyboardIndex.current = count;
setActiveIndex(count);
loadMore();
return;
}
if (count) setActiveIndex((index) => (index + (event.key === "ArrowDown" ? 1 : count - 1)) % count);
return;
}
if ((event.key === "Enter" || event.key === "Tab") && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
if (event.key === "Tab" && (loading || !count)) {
setDismissed(true);
return;
}
event.preventDefault();
if (!loading && count) choose(Math.min(activeIndex, count - 1));
return;
}
}
onKeyDown?.(event);
}
const Control = inputGroup ? InputGroupTextarea : Textarea;
return (
<div className="min-w-0 flex-1 self-stretch">
<Popover
open={open}
onOpenChange={(next) => {
if (!next) setDismissed(true);
}}
>
<PopoverAnchor asChild>
<div>
<Control
{...props}
ref={textarea}
value={value}
disabled={disabled}
className={className}
aria-label={props["aria-label"] ?? t("inputAria")}
aria-autocomplete="list"
aria-controls={open ? listId : undefined}
aria-expanded={open}
aria-activedescendant={open && count ? `${listId}-${Math.min(activeIndex, count - 1)}` : undefined}
onChange={(event) => {
pendingKeyboardIndex.current = null;
onValueChange(event.target.value);
setCursor(event.target.selectionStart);
setDismissed(false);
setActiveIndex(0);
}}
onSelect={(event) => setCursor(event.currentTarget.selectionStart)}
onCompositionStart={() => {
composing.current = true;
}}
onCompositionEnd={() => {
composing.current = false;
}}
onKeyDown={keyDown}
/>
</div>
</PopoverAnchor>
<PopoverContent
side="top"
align="start"
className="w-[min(28rem,calc(100vw-2rem))] gap-1 p-1.5"
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onInteractOutside={(event) => {
if (event.target === textarea.current) event.preventDefault();
}}
aria-label={t("listboxAria")}
>
<div className="flex items-center justify-between px-2 py-1 text-muted-foreground text-xs">
<span>
{categories.length
? t("pickKind")
: t("searchKind", {
kind: search.kind ? t(`kind.${search.kind}`) : t("allRecords"),
})}
</span>
<span>{t("hintKeys")}</span>
</div>
<div
id={listId}
role="listbox"
aria-label={t("candidatesAria")}
className="max-h-60 overflow-y-auto"
onScroll={(event) => {
const list = event.currentTarget;
if (list.scrollHeight - list.scrollTop - list.clientHeight < 64) loadMore();
}}
>
{categories.map((kind, index) => (
<button
key={kind.kind}
id={`${listId}-${index}`}
type="button"
role="option"
aria-selected={activeIndex === index}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm hover:bg-accent",
activeIndex === index && "bg-accent",
)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => choose(index)}
>
<AtSignIcon className="size-4 text-muted-foreground" />
<span>{t(`kind.${kind.kind}`)}</span>
<ChevronRightIcon className="ml-auto size-4 text-muted-foreground" />
</button>
))}
{!categories.length && loading && (
<div role="status" className="flex items-center gap-2 p-3 text-muted-foreground text-sm">
<Loader2Icon className="size-4 animate-spin" />
{t("searching")}
</div>
)}
{!categories.length && !loading && error && (
<div role="alert" className="p-3 text-destructive text-sm">
{t("searchError", { error })}
</div>
)}
{!categories.length && !loading && !error && !items.length && (
<div role="status" className="p-3 text-muted-foreground text-sm">
{t("noMatch")}
</div>
)}
{items.map((item, index) => (
<button
key={`${item.kind}:${item.id}`}
id={`${listId}-${index}`}
type="button"
role="option"
aria-selected={activeIndex === index}
className={cn(
"flex w-full flex-col gap-0.5 rounded-md px-2 py-2 text-left hover:bg-accent",
activeIndex === index && "bg-accent",
)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => choose(index)}
>
<span className="flex w-full items-center gap-2">
<Badge variant="outline">{t(`kind.${item.kind}`)}</Badge>
<span className="truncate text-sm">{item.label}</span>
<span className="ml-auto shrink-0 text-muted-foreground text-xs">#{item.id}</span>
</span>
<span className="w-full truncate text-muted-foreground text-xs">{item.description}</span>
</button>
))}
</div>
{!categories.length && (
<p className="px-2 py-1 text-muted-foreground text-xs">
{loadingMore && <span role="status">{t("loadingMore")}</span>}
{!loadingMore && nextCursor && (
<button type="button" onMouseDown={(event) => event.preventDefault()} onClick={loadMore}>
{pageError ? t("pageError") : t("showingMore", { count: items.length })}
</button>
)}
{!loadingMore &&
!nextCursor &&
!loading &&
!error &&
items.length > 0 &&
t("showingAll", { count: items.length })}
{!loadingMore && !nextCursor && (loading || !!error || items.length === 0) && t("searchHint")}
</p>
)}
</PopoverContent>
</Popover>
{selected.length > 0 && (
<div className="flex flex-wrap items-center gap-1 px-1 pt-1">
{selected.map((item) => {
const label = `${t(`kind.${item.kind}`)} #${item.id}${item.text ? ` · ${item.text}` : ""}`;
return (
<Badge key={item.start} variant="secondary" className="max-w-full gap-1">
<span className="max-w-64 truncate" title={label}>
{label}
</span>
<button
type="button"
disabled={disabled}
aria-label={t("removeAria", { label })}
onClick={() => {
onValueChange(value.slice(0, item.start) + value.slice(item.start + item.token.length));
setCursor(null);
}}
>
<XIcon className="size-3" />
</button>
</Badge>
);
})}
<span className="text-muted-foreground text-xs">{t("selectedHint")}</span>
</div>
)}
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import * as React from "react";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge";
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field";
import { Textarea } from "@/components/ui/textarea";
import { MAX_COMPANY_SCOPE_VALUE_LENGTH, type ParsedCompanyScopeText } from "@/lib/company-scope";
import type { CompanyScopeKind } from "@/lib/types";
// 라벨은 렌더 시점에 t(`kind.${kind}`) 로 해석(아래 배지).
const SCOPE_KINDS: CompanyScopeKind[] = ["domain", "ip", "cidr", "icp", "keyword"];
export function ScopeTextEditor({
id,
value,
onValueChange,
parsed,
label,
description,
}: {
id: string;
value: string;
onValueChange: (value: string) => void;
parsed: ParsedCompanyScopeText;
label?: string;
description?: string;
}) {
const t = useTranslations("scopeEditor");
const counts = React.useMemo(() => {
const result = new Map<CompanyScopeKind, number>();
for (const rule of parsed.rules) result.set(rule.kind, (result.get(rule.kind) ?? 0) + 1);
return result;
}, [parsed.rules]);
// company-scope.ts 는 번역할 수 없는 순수 모듈이라 오류 "코드"만 돌려준다. 여기서
// 코드별 메시지로 해석한다(tooLong 은 최대 글자 수를 함께 보간).
const errorText = React.useCallback(
(code: string) => {
if (code === "tooLong") return t("error.tooLong", { max: MAX_COMPANY_SCOPE_VALUE_LENGTH });
const key = `error.${code}`;
return t.has(key) ? t(key) : code;
},
[t],
);
return (
<Field data-invalid={parsed.errors.length > 0}>
<FieldLabel htmlFor={id}>{label ?? t("defaultLabel")}</FieldLabel>
<FieldDescription>{description ?? t("defaultDescription")}</FieldDescription>
<Textarea
id={id}
rows={8}
value={value}
aria-invalid={parsed.errors.length > 0}
placeholder={t("placeholder")}
className="min-h-36 resize-y font-mono text-sm"
onChange={(event) => onValueChange(event.target.value)}
/>
{parsed.rules.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 text-muted-foreground text-xs">
<span>{t("recognized", { count: parsed.rules.length })}</span>
{SCOPE_KINDS.map((kind) => {
const count = counts.get(kind) ?? 0;
return count > 0 ? (
<Badge key={kind} variant="secondary" className="font-mono tabular-nums">
{t(`kind.${kind}`)} {count}
</Badge>
) : null;
})}
</div>
)}
{parsed.errors.length > 0 && (
<FieldError>
{parsed.errors.slice(0, 5).map((item) => (
<span key={`${item.line}-${item.error}`} className="block">
{t("lineError", { line: item.line, error: errorText(item.error) })}
</span>
))}
{parsed.errors.length > 5 && (
<span className="block">{t("moreErrors", { count: parsed.errors.length - 5 })}</span>
)}
</FieldError>
)}
</Field>
);
}
@@ -0,0 +1,258 @@
"use client";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { ArrowUpIcon, MessageCircleQuestionIcon, SquareIcon, Trash2Icon, XIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { Markdown } from "@/components/markdown";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Skeleton } from "@/components/ui/skeleton";
import { useIsMobile } from "@/hooks/use-mobile";
import type { SideQuestions } from "@/hooks/use-side-questions";
import { cn } from "@/lib/utils";
type ComposerLayout = "inline" | "stacked";
export function SideQuestionButton({ side }: { side: SideQuestions }) {
const t = useTranslations("sideQuestion");
if (!side.enabled) return null;
return (
<Button variant="outline" size="sm" onClick={() => side.setOpen(true)} title={t("buttonTitle")}>
<MessageCircleQuestionIcon data-icon="inline-start" />
{t("button")}
</Button>
);
}
function SidePanel({
side,
label,
composerLayout,
}: {
side: SideQuestions;
label: string;
composerLayout: ComposerLayout;
}) {
const t = useTranslations("sideQuestion");
const inlineComposer = composerLayout === "inline";
const [confirm, setConfirm] = useState(false);
const viewport = useRef<HTMLDivElement>(null);
const pinned = useRef(true);
const tail = side.items.at(-1);
// biome-ignore lint/correctness/useExhaustiveDependencies: New cumulative text scrolls only readers who remain at the bottom.
useEffect(() => {
if (pinned.current && viewport.current) viewport.current.scrollTop = viewport.current.scrollHeight;
}, [tail?.answer, tail?.id]);
return (
<section className="flex h-full min-h-0 flex-col bg-background" aria-label={t("panelLabel")}>
<div className="flex items-center gap-2 border-b p-3">
<div className="min-w-0 flex-1">
<p className="font-medium">
{t("title")} <span className="text-muted-foreground">/btw</span>
</p>
<p className="truncate text-muted-foreground text-xs">{label}</p>
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setConfirm(true)}
disabled={!side.items.length || side.busy}
aria-label={t("clearHistory")}
>
<Trash2Icon />
</Button>
<Button variant="ghost" size="icon-sm" onClick={() => side.setOpen(false)} aria-label={t("closePanel")}>
<XIcon />
</Button>
</div>
<div className="border-b px-3 py-2 text-muted-foreground text-xs">
{side.snapshot ? (
<>
<p>{side.snapshot.model.model}</p>
<p>{t("contextUpdatedAt", { time: new Date(side.snapshot.captured_at).toLocaleString() })}</p>
</>
) : (
t("firstRunHint")
)}
</div>
<div
ref={viewport}
onScroll={(event) => {
const el = event.currentTarget;
pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
}}
className="min-h-0 flex-1 overflow-y-auto p-3"
>
{side.nextCursor > 0 && (
<Button variant="ghost" size="sm" onClick={() => void side.load(side.nextCursor)}>
{t("loadEarlier")}
</Button>
)}
{side.loading && <Skeleton className="h-16 w-full" />}
{!side.loading && side.items.length === 0 && (
<Empty>
<EmptyHeader>
<EmptyTitle>{t("emptyTitle")}</EmptyTitle>
<EmptyDescription>{t("emptyDesc")}</EmptyDescription>
</EmptyHeader>
</Empty>
)}
<div className="flex flex-col gap-5">
{side.items.map((item) => (
<article key={item.id} className="flex min-w-0 flex-col gap-2">
<div className="whitespace-pre-wrap break-words rounded-lg bg-muted p-3 text-sm">{item.question}</div>
<div className="flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
<Badge variant="secondary">{t(`status.${item.status}`)}</Badge>
<span className="truncate">{item.model.model}</span>
<time dateTime={item.snapshot_at} title={new Date(item.snapshot_at).toLocaleString()}>
{t("contextTime", { time: new Date(item.snapshot_at).toLocaleTimeString() })}
</time>
</div>
{item.context?.estimated_input_tokens != null && (
<p className="text-muted-foreground text-xs">
{t("recentExchanges", { count: item.context.recent_exchanges })}
{item.context.history_summarized && ` · ${t("historySummarized")}`}
{item.context.snapshot_summarized && ` · ${t("snapshotSummarized")}`}
</p>
)}
{item.answer && <Markdown text={item.answer} />}
{!item.answer && item.status === "running" && (
<p role="status" className="text-muted-foreground text-sm">
{t(`phase.${item.context?.phase ?? "answering"}`)}
</p>
)}
{item.error && (
<Alert variant="destructive">
<AlertDescription>{item.error}</AlertDescription>
</Alert>
)}
</article>
))}
</div>
</div>
<div className="shrink-0 border-t p-3">
{(side.error || side.snapshot?.reason) && (
<Alert variant="destructive" className="mb-2">
<AlertDescription>{side.error || side.snapshot?.reason}</AlertDescription>
</Alert>
)}
<InputGroup className={inlineComposer ? "min-h-10" : "min-h-9"}>
<InputGroupTextarea
rows={1}
className={cn("overflow-y-auto", inlineComposer ? "max-h-40 min-h-0" : "max-h-36 min-h-9")}
aria-label={t("questionAria")}
placeholder={t("askPlaceholder")}
value={side.draft}
maxLength={4000}
disabled={side.busy}
onChange={(event) => side.setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
void side.ask(side.draft);
}
}}
/>
<InputGroupAddon align={inlineComposer ? "inline-end" : "block-end"}>
{!inlineComposer && <span className="text-muted-foreground text-xs">{t("independentNote")}</span>}
{side.running ? (
<InputGroupButton
className="ml-auto"
variant="destructive"
size="icon-xs"
onClick={() => void side.stop()}
aria-label={t("stopAnswer")}
>
<SquareIcon />
</InputGroupButton>
) : (
<InputGroupButton
className="ml-auto"
variant="default"
size="icon-xs"
onClick={() => void side.ask(side.draft)}
disabled={side.busy || !side.draft.trim() || !side.snapshot?.available}
aria-label={t("sendQuestion")}
>
<ArrowUpIcon />
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
</div>
{inlineComposer && (
<div className="shrink-0 truncate px-3 pt-0.5 pb-1 text-muted-foreground text-xs">{t("independentNote")}</div>
)}
<AlertDialog open={confirm} onOpenChange={setConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("clearConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>{t("clearConfirmDesc")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => void side.clear()}>{t("clearConfirm")}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
export function SideQuestionWorkspace({
side,
label,
children,
composerLayout = "stacked",
}: {
side: SideQuestions;
label: string;
children: ReactNode;
composerLayout?: ComposerLayout;
}) {
const t = useTranslations("sideQuestion");
const mobile = useIsMobile();
return (
<>
<ResizablePanelGroup orientation="horizontal" className="min-h-0 min-w-0 flex-1">
<ResizablePanel id="main-conversation" minSize="35%" className="flex min-h-0 min-w-0 flex-col">
{children}
</ResizablePanel>
{side.open && side.enabled && !mobile && (
<>
<ResizableHandle withHandle />
<ResizablePanel id="side-question" defaultSize="38%" minSize="280px" maxSize="65%">
<SidePanel side={side} label={label} composerLayout={composerLayout} />
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
<Drawer open={mobile && side.open && side.enabled} onOpenChange={side.setOpen}>
<DrawerContent className="h-[85svh]">
<DrawerHeader className="sr-only">
<DrawerTitle>{t("title")}</DrawerTitle>
<DrawerDescription>{t("drawerDesc", { label })}</DrawerDescription>
</DrawerHeader>
<SidePanel side={side} label={label} composerLayout={composerLayout} />
</DrawerContent>
</Drawer>
</>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import type * as React from "react";
import type { SimpleIcon as SimpleIconType } from "simple-icons";
import { cn } from "@/lib/utils";
type SimpleIconProps = {
icon: SimpleIconType;
className?: string;
} & React.SVGProps<SVGSVGElement>;
export function SimpleIcon({ icon, className, ...props }: SimpleIconProps) {
const { title, path } = icon;
return (
<svg
viewBox="0 0 24 24"
aria-label={title}
aria-hidden="false"
focusable="false"
className={cn("size-5 fill-foreground", className)}
{...props}
>
<title>{title}</title>
<path d={path} />
</svg>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useTranslations } from "next-intl";
import { type StatusDomain, statusMeta, type Tone, toneClasses, toneDot } from "@/lib/status";
import { cn } from "@/lib/utils";
export function StatusBadge({
domain,
value,
dot = false,
className,
}: {
domain: StatusDomain;
value: string;
dot?: boolean;
className?: string;
}) {
const meta = statusMeta(domain, value);
// Shared status labels live in the "status" message namespace (ko/zh);
// lib/status.ts only carries the color tone. The raw value is the last-resort
// fallback for an unknown status (every real status.* key exists in ko/zh).
const t = useTranslations("status");
const key = `${domain}.${value}`;
const label = t.has(key) ? t(key) : value;
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium whitespace-nowrap",
toneClasses[meta.tone],
className,
)}
>
{dot && <span className={cn("size-1.5 rounded-full", toneDot[meta.tone])} />}
{label}
</span>
);
}
export function ToneDot({ tone }: { tone: Tone }) {
return <span className={cn("size-2 rounded-full", toneDot[tone])} />;
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useEffect, useRef } from "react";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem } from "@/components/ui/pagination";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface TablePaginationProps {
page: number; // 1-based
pageSize: number;
total: number; // total (after filtering)
onPageChange: (p: number) => void;
onPageSizeChange: (s: number) => void;
pageSizeOptions?: number[];
}
// pageWindows returns the sequence of page numbers / ellipsis to render.
function pageWindows(page: number, total: number): (number | "...")[] {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const out: (number | "...")[] = [1];
if (page > 3) out.push("...");
const lo = Math.max(2, page - 1);
const hi = Math.min(total - 1, page + 1);
for (let i = lo; i <= hi; i++) out.push(i);
if (page < total - 2) out.push("...");
out.push(total);
return out;
}
export function TablePagination({
page,
pageSize,
total,
onPageChange,
onPageSizeChange,
pageSizeOptions = [10, 20, 50],
}: TablePaginationProps) {
const t = useTranslations("pagination");
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(Math.max(1, page), totalPages);
const from = total === 0 ? 0 : (safePage - 1) * pageSize + 1;
const to = Math.min(safePage * pageSize, total);
const onPageChangeRef = useRef(onPageChange);
onPageChangeRef.current = onPageChange;
useEffect(() => {
if (page !== safePage) onPageChangeRef.current(safePage);
}, [page, safePage]);
return (
<div className="flex flex-wrap items-center justify-between gap-2 border-t px-4 py-2 lg:px-6">
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<Select value={String(pageSize)} onValueChange={(v) => onPageSizeChange(Number(v))}>
<SelectTrigger size="sm" className="h-7 w-16">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((s) => (
<SelectItem key={s} value={String(s)}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
<span>{t("perPage")}</span>
{total > 0 ? (
<span className="tabular-nums">{t("range", { from, to, total })}</span>
) : (
<span>{t("empty")}</span>
)}
</div>
{totalPages > 1 && (
<Pagination className="mx-0 w-auto justify-end">
<PaginationContent>
<PaginationItem>
<Button
variant="ghost"
size="icon-sm"
disabled={safePage === 1}
onClick={() => onPageChange(safePage - 1)}
aria-label={t("prev")}
>
<ChevronLeftIcon className="size-4" />
</Button>
</PaginationItem>
{pageWindows(safePage, totalPages).map((p, i) =>
p === "..." ? (
<PaginationItem key={`el-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={p}>
<Button
variant={p === safePage ? "outline" : "ghost"}
size="icon-sm"
onClick={() => onPageChange(p)}
aria-current={p === safePage ? "page" : undefined}
>
{p}
</Button>
</PaginationItem>
),
)}
<PaginationItem>
<Button
variant="ghost"
size="icon-sm"
disabled={safePage === totalPages}
onClick={() => onPageChange(safePage + 1)}
aria-label={t("next")}
>
<ChevronRightIcon className="size-4" />
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
);
}
@@ -0,0 +1,226 @@
"use client";
import * as React from "react";
import { ArrowDownIcon, ArrowUpIcon, XIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
} from "@/components/ui/combobox";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { LLMProfile } from "@/lib/types";
interface TaskLLMProfileChainProps {
profiles: LLMProfile[];
value: string[];
onValueChange: (value: string[]) => void;
activeProfileId?: string;
onActiveProfileChange?: (value: string) => void;
disabled?: boolean;
inputId?: string;
portalContainer?: React.RefObject<HTMLElement | null>;
}
function ProfileRoleBadge({ index, currentIndex }: { index: number; currentIndex: number }) {
const t = useTranslations("llmChain");
if (index === currentIndex) return <Badge variant="default">{t("current")}</Badge>;
if (index < currentIndex) {
return (
<Badge variant="outline" title={t("skippedTitle")}>
{t("skipped")}
</Badge>
);
}
return <Badge variant="secondary">{t("backup", { n: index - currentIndex })}</Badge>;
}
export function TaskLLMProfileChain({
profiles,
value,
onValueChange,
activeProfileId,
onActiveProfileChange,
disabled = false,
inputId,
portalContainer,
}: TaskLLMProfileChainProps) {
const t = useTranslations("llmChain");
const profilesByID = React.useMemo(() => new Map(profiles.map((profile) => [profile.id, profile])), [profiles]);
const itemIDs = React.useMemo(() => profiles.map((profile) => profile.id), [profiles]);
const profilesUnavailable = profiles.length === 0;
const currentProfileID = activeProfileId && value.includes(activeProfileId) ? activeProfileId : value[0];
const currentIndex = value.indexOf(currentProfileID);
// api_key_hint is display-only and is empty for valid keys shorter than four
// characters. The task API remains authoritative for profile validation.
const profileLabel = React.useCallback(
(id: string) => {
const profile = profilesByID.get(id);
return profile ? `${profile.name} ${profile.model}` : t("profileRef", { id });
},
[profilesByID, t],
);
const move = (index: number, offset: -1 | 1) => {
const target = index + offset;
if (target < 0 || target >= value.length) return;
const next = [...value];
[next[index], next[target]] = [next[target], next[index]];
onValueChange(next);
};
const remove = (id: string) => {
const next = value.filter((profileID) => profileID !== id);
onValueChange(next);
if (activeProfileId === id) onActiveProfileChange?.(next[0] ?? "");
};
const handleValueChange = (next: string[]) => {
onValueChange(next);
if (activeProfileId && !next.includes(activeProfileId)) {
onActiveProfileChange?.(next[0] ?? "");
}
};
return (
<div className="flex flex-col gap-3">
<Combobox
items={itemIDs}
itemToStringValue={profileLabel}
multiple
value={value}
onValueChange={handleValueChange}
disabled={disabled}
>
<ComboboxChips>
<ComboboxValue>
{value.map((id) => (
<ComboboxChip key={id}>{profilesByID.get(id)?.name ?? `#${id}`}</ComboboxChip>
))}
</ComboboxValue>
<ComboboxChipsInput
id={inputId}
placeholder={profiles.length > 0 ? t("searchPlaceholder") : t("noProfiles")}
disabled={disabled ? true : profilesUnavailable}
/>
</ComboboxChips>
<ComboboxContent portalContainer={portalContainer}>
<ComboboxEmpty>{t("noMatch")}</ComboboxEmpty>
<ComboboxList>
{(id) => {
const profile = profilesByID.get(id);
return (
<ComboboxItem key={id} value={id} disabled={!profile}>
<div className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">
{profile?.name ?? t("profileRef", { id })}
{profile?.is_default ? t("activeSuffix") : ""}
</span>
</span>
{profile && <span className="truncate text-muted-foreground text-xs">{profile.model}</span>}
</div>
</ComboboxItem>
);
}}
</ComboboxList>
</ComboboxContent>
</Combobox>
{value.length === 0 ? (
<p className="text-muted-foreground text-xs">{t("emptyHint")}</p>
) : (
<div className="flex flex-col divide-y rounded-lg border">
{value.map((id, index) => {
const profile = profilesByID.get(id);
return (
<div
key={id}
className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 px-2.5 py-2 sm:flex"
>
<span className="w-5 shrink-0 text-center text-muted-foreground text-xs tabular-nums">{index + 1}</span>
<div className="min-w-0 sm:flex-1">
<p className="truncate font-medium text-sm">{profile?.name ?? t("profileRef", { id })}</p>
<div className="flex min-w-0 items-center gap-2">
<p className="truncate text-muted-foreground text-xs">{profile?.model ?? t("unavailable")}</p>
</div>
</div>
<ProfileRoleBadge index={index} currentIndex={currentIndex} />
<div className="col-span-2 col-start-2 flex shrink-0 items-center gap-1 justify-self-end sm:col-auto sm:justify-self-auto">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t("moveUp")}
onClick={() => move(index, -1)}
disabled={disabled ? true : index === 0}
>
<ArrowUpIcon data-icon="inline-start" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t("moveDown")}
onClick={() => move(index, 1)}
disabled={disabled ? true : index === value.length - 1}
>
<ArrowDownIcon data-icon="inline-start" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={t("remove")}
onClick={() => remove(id)}
disabled={disabled}
>
<XIcon data-icon="inline-start" />
</Button>
</div>
</div>
);
})}
</div>
)}
{onActiveProfileChange && value.length > 0 && (
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<span className="font-medium text-sm">{t("currentProfile")}</span>
<Select
value={activeProfileId && value.includes(activeProfileId) ? activeProfileId : value[0]}
onValueChange={onActiveProfileChange}
disabled={disabled}
>
<SelectTrigger size="sm" className="w-full sm:min-w-48 sm:max-w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{value.map((id) => {
const profile = profilesByID.get(id);
return (
<SelectItem key={id} value={id} disabled={!profile}>
{profile?.name ?? t("profileRef", { id })}
</SelectItem>
);
})}
</SelectGroup>
</SelectContent>
</Select>
</div>
)}
</div>
);
}
@@ -0,0 +1,540 @@
"use client";
import * as React from "react";
import { LibraryIcon, PlusIcon, SaveIcon, Settings2Icon, Trash2Icon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { AssetInterceptRulesEditor } from "@/components/asset-intercept-rules-editor";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import type { AssetInterceptRuleInput, TaskCategory, TaskTemplate } from "@/lib/types";
import { cn } from "@/lib/utils";
interface TemplateDraft {
name: string;
description: string;
goal: string;
categoryID: number | null;
interceptRules: AssetInterceptRuleInput[];
}
// TemplateSeed 是「另存为模板」时从创建表单带入的初值。
type TemplateSeed = Pick<TemplateDraft, "description" | "goal" | "categoryID" | "interceptRules">;
interface TaskTemplateManagerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
templates: TaskTemplate[];
seed: TemplateSeed | null;
onCreated: (template: TaskTemplate) => void;
onUpdated: (template: TaskTemplate) => void;
onDeleted: (id: number) => void;
}
const emptyDraft = (): TemplateDraft => ({
name: "",
description: "",
goal: "",
categoryID: null,
interceptRules: [],
});
function templateDraft(template: TaskTemplate): TemplateDraft {
return {
name: template.name,
description: template.description,
goal: template.goal,
categoryID: template.category_id ?? null,
interceptRules: template.intercept_rules ?? [],
};
}
function TaskTemplateManager({
open,
onOpenChange,
templates,
seed,
onCreated,
onUpdated,
onDeleted,
}: TaskTemplateManagerProps) {
const t = useTranslations("taskTemplate");
const [selectedID, setSelectedID] = React.useState<number | null>(null);
const [draft, setDraft] = React.useState<TemplateDraft>(emptyDraft);
const [saving, setSaving] = React.useState(false);
const [deleteOpen, setDeleteOpen] = React.useState(false);
const [deleting, setDeleting] = React.useState(false);
const [categories, setCategories] = React.useState<TaskCategory[]>([]);
const wasOpen = React.useRef(false);
React.useEffect(() => {
if (!open) return;
api
.taskCategories()
.then(setCategories)
.catch(() => setCategories([]));
}, [open]);
React.useEffect(() => {
if (open && !wasOpen.current) {
if (seed) {
setSelectedID(null);
setDraft({
name: "",
description: seed.description,
goal: seed.goal,
categoryID: seed.categoryID,
interceptRules: seed.interceptRules,
});
} else if (templates[0]) {
setSelectedID(templates[0].id);
setDraft(templateDraft(templates[0]));
} else {
setSelectedID(null);
setDraft(emptyDraft());
}
}
wasOpen.current = open;
}, [open, seed, templates]);
const selectTemplate = (template: TaskTemplate) => {
setSelectedID(template.id);
setDraft(templateDraft(template));
};
const startNew = () => {
setSelectedID(null);
setDraft(emptyDraft());
};
const updateDraft = (field: "name" | "description" | "goal", value: string) => {
setDraft((current) => ({ ...current, [field]: value }));
};
const patchDraft = (patch: Partial<TemplateDraft>) => {
setDraft((current) => ({ ...current, ...patch }));
};
async function save() {
const input = {
name: draft.name.trim(),
description: draft.description.trim(),
goal: draft.goal.trim(),
category_id: draft.categoryID,
intercept_rules: draft.interceptRules
.map((r) => ({ ...r, pattern: r.pattern.trim() }))
.filter((r) => r.pattern !== ""),
};
if (!input.name || !input.description || !input.goal) {
toast.error(t("manager.validation"));
return;
}
setSaving(true);
try {
if (selectedID == null) {
const created = await api.createTaskTemplate(input);
onCreated(created);
setSelectedID(created.id);
setDraft(templateDraft(created));
toast.success(t("manager.created"));
} else {
const updated = await api.updateTaskTemplate(selectedID, input);
onUpdated(updated);
setDraft(templateDraft(updated));
toast.success(t("manager.updated"));
}
} catch (error) {
toast.error(t("manager.saveError", { error: (error as Error).message }));
} finally {
setSaving(false);
}
}
async function remove() {
if (selectedID == null) return;
const deletedID = selectedID;
setDeleting(true);
try {
await api.deleteTaskTemplate(deletedID);
onDeleted(deletedID);
const next = templates.find((template) => template.id !== deletedID);
if (next) {
setSelectedID(next.id);
setDraft(templateDraft(next));
} else {
startNew();
}
setDeleteOpen(false);
toast.success(t("manager.deleted"));
} catch (error) {
toast.error(t("manager.deleteError", { error: (error as Error).message }));
} finally {
setDeleting(false);
}
}
let saveLabel = saving ? t("manager.saving") : t("manager.saveChanges");
if (!saving && selectedID == null) saveLabel = t("manager.create");
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="grid h-full w-full! max-w-none! grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
<SheetHeader className="border-b px-6 py-5">
<SheetTitle>{t("manager.title")}</SheetTitle>
<SheetDescription>{t("manager.description")}</SheetDescription>
</SheetHeader>
<div className="grid min-h-0 overflow-y-auto lg:grid-cols-[15rem_minmax(0,1fr)] lg:overflow-hidden">
<div className="flex min-h-0 flex-col border-b p-3 lg:border-r lg:border-b-0">
<Button type="button" variant="outline" className="w-full" onClick={startNew}>
<PlusIcon data-icon="inline-start" />
{t("manager.new")}
</Button>
<ScrollArea className="mt-2 max-h-44 lg:max-h-none lg:flex-1">
<div className="flex flex-col gap-1 pr-2">
{templates.length === 0 && (
<p className="px-2 py-6 text-center text-muted-foreground text-sm">{t("manager.noTemplates")}</p>
)}
{templates.map((template) => (
<button
key={template.id}
type="button"
className={cn(
"min-w-0 rounded-md px-2.5 py-2 text-left transition-colors",
selectedID === template.id ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
)}
onClick={() => selectTemplate(template)}
>
<span className="block truncate font-medium text-sm">{template.name}</span>
<span className="block truncate text-muted-foreground text-xs">{template.description}</span>
</button>
))}
</div>
</ScrollArea>
</div>
<ScrollArea className="min-h-0">
<FieldGroup className="p-6">
<Field>
<FieldLabel htmlFor="task-template-name">{t("manager.nameLabel")}</FieldLabel>
<Input
id="task-template-name"
value={draft.name}
maxLength={120}
placeholder={t("manager.namePlaceholder")}
onChange={(event) => updateDraft("name", event.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="task-template-description">{t("manager.descriptionLabel")}</FieldLabel>
<Textarea
id="task-template-description"
className="min-h-28"
value={draft.description}
placeholder={t("manager.descriptionPlaceholder")}
onChange={(event) => updateDraft("description", event.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="task-template-goal">{t("manager.goalLabel")}</FieldLabel>
<Textarea
id="task-template-goal"
className="min-h-28"
value={draft.goal}
placeholder={t("manager.goalPlaceholder")}
onChange={(event) => updateDraft("goal", event.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="task-template-category">{t("manager.categoryLabel")}</FieldLabel>
<NativeSelect
id="task-template-category"
className="w-full"
value={draft.categoryID == null ? "" : String(draft.categoryID)}
onChange={(event) =>
patchDraft({ categoryID: event.target.value === "" ? null : Number(event.target.value) })
}
>
<NativeSelectOption value="">{t("manager.categoryUnclassified")}</NativeSelectOption>
{categories.map((c) => (
<NativeSelectOption key={c.id} value={String(c.id)}>
{c.name}
</NativeSelectOption>
))}
</NativeSelect>
<FieldDescription>{t("manager.categoryHint")}</FieldDescription>
</Field>
<Field>
<FieldLabel>{t("manager.rulesLabel")}</FieldLabel>
<AssetInterceptRulesEditor
value={draft.interceptRules}
onChange={(rules) => patchDraft({ interceptRules: rules })}
/>
<FieldDescription>{t("manager.rulesHint")}</FieldDescription>
</Field>
</FieldGroup>
</ScrollArea>
</div>
<SheetFooter className="border-t px-6 py-4 sm:flex-row sm:items-center">
{selectedID != null && (
<Button type="button" variant="destructive" className="sm:mr-auto" onClick={() => setDeleteOpen(true)}>
<Trash2Icon data-icon="inline-start" />
{t("manager.delete")}
</Button>
)}
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t("manager.close")}
</Button>
<Button type="button" disabled={saving} onClick={() => void save()}>
{saving ? <Spinner data-icon="inline-start" /> : <SaveIcon data-icon="inline-start" />}
{saveLabel}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("deleteDialog.title", { name: draft.name || t("deleteDialog.untitled") })}
</AlertDialogTitle>
<AlertDialogDescription>{t("deleteDialog.description")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>{t("deleteDialog.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleting}
onClick={(event) => {
event.preventDefault();
void remove();
}}
>
{deleting && <Spinner data-icon="inline-start" />}
{deleting ? t("deleteDialog.deleting") : t("deleteDialog.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
interface TaskTemplateControlsProps {
description: string;
goal: string;
categoryID?: number;
interceptRules?: AssetInterceptRuleInput[];
selectedTemplateID: number | null;
onSelectedTemplateIDChange: (id: number | null) => void;
onApply: (template: TaskTemplate) => void;
portalContainer?: React.RefObject<HTMLElement | null>;
}
export function TaskTemplateControls({
description,
goal,
categoryID,
interceptRules,
selectedTemplateID,
onSelectedTemplateIDChange,
onApply,
portalContainer,
}: TaskTemplateControlsProps) {
const t = useTranslations("taskTemplate");
const [templates, setTemplates] = React.useState<TaskTemplate[]>([]);
const [loading, setLoading] = React.useState(true);
const [templateInputValue, setTemplateInputValue] = React.useState("");
const [pendingTemplate, setPendingTemplate] = React.useState<TaskTemplate | null>(null);
const [managerOpen, setManagerOpen] = React.useState(false);
const [managerSeed, setManagerSeed] = React.useState<TemplateSeed | null>(null);
const loadTemplates = React.useCallback(async () => {
setLoading(true);
try {
setTemplates(await api.taskTemplates());
} catch (error) {
setTemplates([]);
toast.error(t("picker.loadError", { error: (error as Error).message }));
} finally {
setLoading(false);
}
}, [t]);
React.useEffect(() => {
void loadTemplates();
}, [loadTemplates]);
const selectedTemplate = React.useMemo(
() => templates.find((template) => template.id === selectedTemplateID) ?? null,
[selectedTemplateID, templates],
);
React.useEffect(() => {
setTemplateInputValue(selectedTemplate?.name ?? "");
}, [selectedTemplate]);
const applyTemplate = (template: TaskTemplate) => {
onApply(template);
onSelectedTemplateIDChange(template.id);
setPendingTemplate(null);
};
const chooseTemplate = (template: TaskTemplate | null) => {
if (!template) {
setTemplateInputValue("");
onSelectedTemplateIDChange(null);
return;
}
setTemplateInputValue(template.name);
const hasContent = description.trim() !== "" || goal.trim() !== "";
const changesContent = description !== template.description || goal !== template.goal;
if (hasContent && changesContent) {
setPendingTemplate(template);
return;
}
applyTemplate(template);
};
const openManager = (seed: TemplateSeed | null) => {
setManagerSeed(seed);
setManagerOpen(true);
};
const upsertTemplate = (template: TaskTemplate) => {
setTemplates((current) => [template, ...current.filter((item) => item.id !== template.id)]);
};
const deleteTemplate = (id: number) => {
setTemplates((current) => current.filter((template) => template.id !== id));
if (selectedTemplateID === id) onSelectedTemplateIDChange(null);
};
let pickerPlaceholder = loading ? t("picker.loading") : t("picker.empty");
if (!loading && templates.length > 0) pickerPlaceholder = t("picker.search");
return (
<>
<Field>
<div className="flex flex-wrap items-center justify-between gap-2">
<FieldLabel htmlFor="task-template-picker">{t("picker.label")}</FieldLabel>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="ghost" size="sm" onClick={() => openManager(null)}>
<Settings2Icon data-icon="inline-start" />
{t("picker.manage")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!description.trim() || !goal.trim()}
onClick={() =>
openManager({
description,
goal,
categoryID: categoryID ?? null,
interceptRules: interceptRules ?? [],
})
}
>
<SaveIcon data-icon="inline-start" />
{t("picker.saveAs")}
</Button>
</div>
</div>
<Combobox
items={templates}
itemToStringLabel={(template) => template.name}
itemToStringValue={(template) => String(template.id)}
inputValue={templateInputValue}
onInputValueChange={(value) => setTemplateInputValue(value)}
value={selectedTemplate}
onValueChange={chooseTemplate}
>
<ComboboxInput
id="task-template-picker"
className="w-full"
placeholder={pickerPlaceholder}
disabled={loading || templates.length === 0}
showClear
/>
<ComboboxContent portalContainer={portalContainer}>
<ComboboxEmpty>{t("picker.noMatch")}</ComboboxEmpty>
<ComboboxList>
{(template) => (
<ComboboxItem key={template.id} value={template}>
<LibraryIcon />
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{template.name}</p>
{template.description && (
<p className="truncate text-muted-foreground text-xs">{template.description}</p>
)}
</div>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<FieldDescription>{t("picker.hint")}</FieldDescription>
</Field>
<AlertDialog
open={pendingTemplate != null}
onOpenChange={(open) => {
if (open) return;
setPendingTemplate(null);
setTemplateInputValue(selectedTemplate?.name ?? "");
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("apply.title", { name: pendingTemplate?.name ?? "" })}</AlertDialogTitle>
<AlertDialogDescription>{t("apply.description")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("apply.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => pendingTemplate && applyTemplate(pendingTemplate)}>
{t("apply.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<TaskTemplateManager
open={managerOpen}
onOpenChange={setManagerOpen}
templates={templates}
seed={managerSeed}
onCreated={upsertTemplate}
onUpdated={upsertTemplate}
onDeleted={deleteTemplate}
/>
</>
);
}
+90
View File
@@ -0,0 +1,90 @@
"use client";
import * as React from "react";
import { ListTodo } from "lucide-react";
import { useTranslations } from "next-intl";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
// TodoPopover shows the latest TodoWrite state of a session (chat conversation or
// task worker/planner replay). The full todo list isn't in the activity list
// (detail is lazy), so on open it fetches the detail of the most-recent TodoWrite
// call — by seq — and parses its {todos:[…]} JSON. Purely client-side; the caller
// supplies the seq + a detail fetcher (conversation or exploration endpoint).
export function TodoPopover({
seq,
fetchDetail,
}: {
seq: number | null;
fetchDetail: (seq: number) => Promise<string>;
}) {
const tr = useTranslations("todoPopover");
const [open, setOpen] = React.useState(false);
const [todos, setTodos] = React.useState<{ content: string; status: string }[] | null>(null);
const [loading, setLoading] = React.useState(false);
const [err, setErr] = React.useState("");
const load = React.useCallback(async () => {
if (seq == null) return;
setLoading(true);
setErr("");
try {
const detail = await fetchDetail(seq);
const start = detail.indexOf("{"); // detail 可能带 "TodoWrite " 前缀
const parsed = JSON.parse(start >= 0 ? detail.slice(start) : detail);
setTodos(Array.isArray(parsed?.todos) ? parsed.todos : []);
} catch {
setErr(tr("parseError"));
setTodos(null);
} finally {
setLoading(false);
}
}, [seq, fetchDetail, tr]);
// refetch on each open — todos change as the run progresses.
React.useEffect(() => {
if (open) void load();
}, [open, load]);
const disabled = seq == null;
const MARK: Record<string, string> = { pending: "☐", in_progress: "▶", completed: "✔" };
return (
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
title={disabled ? tr("none") : tr("view")}
className="text-muted-foreground/70 hover:text-primary flex items-center gap-0.5 text-xs disabled:pointer-events-none disabled:opacity-40"
>
<ListTodo className="size-3" />
Todo
</button>
</PopoverTrigger>
<PopoverContent align="end" className="max-h-80 w-80 overflow-auto p-2">
<p className="text-muted-foreground px-1 pb-1 text-[11px] font-medium">
{tr("recent")}
{loading ? tr("loadingSuffix") : ""}
</p>
{err && <p className="text-destructive px-1 text-xs">{err}</p>}
{todos && todos.length === 0 && !loading && <p className="text-muted-foreground px-1 text-xs">{tr("empty")}</p>}
<ul className="space-y-0.5">
{(todos ?? []).map((t, i) => (
<li
key={`${i}:${t.content}`}
className={cn(
"flex gap-1.5 px-1 text-xs",
t.status === "completed" && "text-muted-foreground line-through",
)}
>
<span className="shrink-0">{MARK[t.status] ?? "☐"}</span>
<span className="break-words">{t.content}</span>
</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,185 @@
"use client";
import * as React from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { HttpCodeBlock } from "@/components/http-code-block";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api";
import type { FindingTrafficDetail, TrafficDetail } from "@/lib/types";
export function TrafficEvidenceViewer({
findingId,
bindingId,
contextTask,
onClose,
}: {
findingId: string;
bindingId: string | null;
contextTask?: string;
onClose: () => void;
}) {
const t = useTranslations("trafficEvidence");
const [detail, setDetail] = React.useState<FindingTrafficDetail | null>(null);
const [error, setError] = React.useState("");
const [busy, setBusy] = React.useState(false);
React.useEffect(() => {
if (!bindingId) return;
let active = true;
setDetail(null);
setError("");
api
.findingTrafficDetail(findingId, bindingId, contextTask)
.then((d) => {
if (active) setDetail(d);
})
.catch((e: Error) => {
if (active) setError(e.message);
});
return () => {
active = false;
};
}, [findingId, bindingId, contextTask]);
async function more(side: "request" | "response") {
if (!detail || !bindingId) return;
setBusy(true);
try {
const next = await api.findingTrafficBody(findingId, bindingId, side, detail[side].next_offset, contextTask);
setDetail((current) =>
current?.binding.id === bindingId
? { ...current, [side]: { ...next, content: current[side].content + next.content } }
: current,
);
} catch (e) {
toast.error((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<Dialog
open={bindingId !== null}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<DialogTitle>{t("viewer.title", { id: bindingId ?? "" })}</DialogTitle>
<DialogDescription className="break-all">
{detail?.binding.snapshot.url ?? t("viewer.fallbackDesc")}
</DialogDescription>
</DialogHeader>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : detail ? (
<Tabs defaultValue="request">
<TabsList>
<TabsTrigger value="request">{t("viewer.request")}</TabsTrigger>
<TabsTrigger value="response">{t("viewer.response")}</TabsTrigger>
</TabsList>
{(["request", "response"] as const).map((side) => (
<TabsContent key={side} value={side}>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
{t("viewer.bodyBytes", { bytes: detail[side].total.toLocaleString() })}
{detail[side].truncated ? t("viewer.previewSuffix") : ""}
</span>
<Button
variant="outline"
size="sm"
onClick={() =>
void api
.downloadFindingTrafficBody(findingId, detail.binding.id, side, contextTask)
.catch((e: Error) => toast.error(e.message))
}
>
{side === "request" ? t("viewer.downloadRequest") : t("viewer.downloadResponse")}
</Button>
</div>
<HttpCodeBlock
raw={`${(side === "request" ? detail.binding.snapshot.req_head : detail.binding.snapshot.resp_head)?.trimEnd() ?? ""}\n\n${detail[side].content}`}
/>
{detail[side].truncated && !detail[side].binary ? (
<Button variant="outline" size="sm" disabled={busy} onClick={() => void more(side)}>
{t("viewer.loadMore")}
</Button>
) : null}
</TabsContent>
))}
</Tabs>
) : (
<Skeleton className="h-56 w-full" />
)}
</DialogContent>
</Dialog>
);
}
export function CapturedTrafficViewer({ id, onClose }: { id: string | null; onClose: () => void }) {
const t = useTranslations("trafficEvidence");
const [detail, setDetail] = React.useState<TrafficDetail | null>(null);
const [error, setError] = React.useState("");
React.useEffect(() => {
if (!id) return;
let active = true;
setDetail(null);
setError("");
api
.trafficExchange(id)
.then((d) => {
if (active) setDetail(d);
})
.catch((e: Error) => {
if (active) setError(e.message);
});
return () => {
active = false;
};
}, [id]);
return (
<Dialog
open={id !== null}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<DialogTitle>{t("captured.title")}</DialogTitle>
<DialogDescription>{t("captured.description", { id: id ?? "" })}</DialogDescription>
</DialogHeader>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : detail ? (
<Tabs defaultValue="request">
<TabsList>
<TabsTrigger value="request">{t("viewer.request")}</TabsTrigger>
<TabsTrigger value="response">{t("viewer.response")}</TabsTrigger>
</TabsList>
<TabsContent value="request">
<HttpCodeBlock raw={detail.req} />
</TabsContent>
<TabsContent value="response">
<HttpCodeBlock raw={detail.resp} />
</TabsContent>
</Tabs>
) : (
<Skeleton className="h-56 w-full" />
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,277 @@
"use client";
import * as React from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { CapturedTrafficViewer } from "@/components/traffic-evidence-viewer";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api } from "@/lib/api";
import type { FindingTrafficBinding, TrafficResp } from "@/lib/types";
const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
export function TrafficPickerDialog({
findingId,
contextTask,
bound,
onClose,
onBound,
}: {
findingId: string;
contextTask?: string;
bound: FindingTrafficBinding[];
onClose: () => void;
onBound: () => void;
}) {
const t = useTranslations("trafficEvidence");
const [host, setHost] = React.useState("");
const [method, setMethod] = React.useState("all");
const [query, setQuery] = React.useState("");
const [page, setPage] = React.useState(0);
const [data, setData] = React.useState<TrafficResp | null>(null);
const [selected, setSelected] = React.useState<Set<string>>(() => new Set());
const [preview, setPreview] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(true);
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState("");
const alreadyBound = new Set(bound.map((b) => b.snapshot.source_traffic_id));
React.useEffect(() => {
let active = true;
setLoading(true);
setError("");
const timer = setTimeout(() => {
api
.traffic(page, 25, host, method === "all" ? "" : method, query)
.then((d) => {
if (active) setData(d);
})
.catch((e: Error) => {
if (active) setError(e.message);
})
.finally(() => {
if (active) setLoading(false);
});
}, 250);
return () => {
active = false;
clearTimeout(timer);
};
}, [page, host, method, query]);
const rows = data?.exchanges ?? [];
const selectable = rows.filter((e) => !alreadyBound.has(e.id));
function toggle(id: string, checked: boolean) {
setSelected((previous) => {
const next = new Set(previous);
if (checked) next.add(id);
else next.delete(id);
return next;
});
}
async function save() {
setBusy(true);
setError("");
try {
await api.bindFindingTraffic(
findingId,
[...selected].map((traffic_id) => ({ traffic_id })),
contextTask,
);
toast.success(t("picker.boundToast", { count: selected.size }));
onBound();
onClose();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<>
<Dialog
open
onOpenChange={(open) => {
if (!open && !busy) onClose();
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-5xl">
<DialogHeader>
<DialogTitle>{t("picker.title")}</DialogTitle>
<DialogDescription>{t("picker.description")}</DialogDescription>
</DialogHeader>
<FieldGroup className="flex flex-col gap-3 sm:flex-row">
<Field>
<FieldLabel htmlFor="evidence-host">{t("picker.host")}</FieldLabel>
<Input
id="evidence-host"
value={host}
placeholder={t("picker.hostPlaceholder")}
onChange={(e) => {
setHost(e.target.value);
setPage(0);
}}
/>
</Field>
<Field>
<FieldLabel htmlFor="evidence-method">{t("picker.method")}</FieldLabel>
<Select
value={method}
onValueChange={(v) => {
setMethod(v);
setPage(0);
}}
>
<SelectTrigger id="evidence-method">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">{t("picker.allMethods")}</SelectItem>
{METHODS.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="evidence-query">{t("picker.keyword")}</FieldLabel>
<Input
id="evidence-query"
value={query}
placeholder={t("picker.keywordPlaceholder")}
onChange={(e) => {
setQuery(e.target.value);
setPage(0);
}}
/>
</Field>
</FieldGroup>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<div className="max-h-[45vh] overflow-auto rounded-md border" aria-busy={loading}>
<Table>
<TableHeader>
<TableRow>
<TableHead>
<Checkbox
aria-label={t("picker.selectPage")}
disabled={loading || busy || selectable.length === 0}
checked={selectable.length > 0 && selectable.every((e) => selected.has(e.id))}
onCheckedChange={(checked) =>
setSelected((previous) => {
const next = new Set(previous);
for (const e of selectable) {
if (checked === true) next.add(e.id);
else next.delete(e.id);
}
return next;
})
}
/>
</TableHead>
<TableHead>{t("picker.colMethodUrl")}</TableHead>
<TableHead>{t("picker.colTime")}</TableHead>
<TableHead>{t("picker.colStatus")}</TableHead>
<TableHead>{t("picker.colAction")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((e) => (
<TableRow key={e.id}>
<TableCell>
<Checkbox
aria-label={t("picker.selectOne", { id: e.id })}
checked={selected.has(e.id) || alreadyBound.has(e.id)}
disabled={busy || loading || alreadyBound.has(e.id)}
onCheckedChange={(checked) => toggle(e.id, checked === true)}
/>
</TableCell>
<TableCell>
<span className="font-mono text-xs">
{e.method} {e.url}
</span>
{alreadyBound.has(e.id) ? <Badge variant="secondary">{t("picker.bound")}</Badge> : null}
</TableCell>
<TableCell className="whitespace-nowrap text-xs">
{new Date(e.ts).toLocaleString("ko-KR")}
</TableCell>
<TableCell>{e.status}</TableCell>
<TableCell>
<Button variant="ghost" size="sm" onClick={() => setPreview(e.id)}>
{t("picker.preview")}
</Button>
</TableCell>
</TableRow>
))}
{!rows.length ? (
<TableRow>
<TableCell colSpan={5} className="py-8 text-center">
{loading ? t("picker.loading") : t("picker.noMatch")}
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm">
{t("picker.selectedSummary", { selected: selected.size, total: data?.total ?? 0 })}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={loading || page === 0}
onClick={() => setPage((p) => p - 1)}
>
{t("picker.prevPage")}
</Button>
<span className="text-xs">{t("picker.pageNo", { page: page + 1 })}</span>
<Button
variant="outline"
size="sm"
disabled={loading || (page + 1) * 25 >= (data?.total ?? 0)}
onClick={() => setPage((p) => p + 1)}
>
{t("picker.nextPage")}
</Button>
</div>
</div>
<DialogFooter>
<Button variant="outline" disabled={busy} onClick={onClose}>
{t("picker.cancel")}
</Button>
<Button disabled={busy || selected.size === 0} onClick={() => void save()}>
{busy ? t("picker.saving") : t("picker.bindCount", { count: selected.size })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<CapturedTrafficViewer id={preview} onClose={() => setPreview(null)} />
</>
);
}
+802
View File
@@ -0,0 +1,802 @@
"use client";
import * as React from "react";
import {
CheckIcon,
ChevronDown,
ChevronRight,
CrosshairIcon,
Flag,
MessageSquare,
PaperclipIcon,
ShieldAlertIcon,
Terminal,
UserIcon,
Wrench,
XIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { ApprovalDetail } from "@/components/approval-records";
import { Markdown } from "@/components/markdown";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { api } from "@/lib/api";
import type { Activity, InterceptPending } from "@/lib/types";
// ---- per-agent lane color (planner + work#1/#2/#3 …) ---------------------------
const workerColors = ["bg-sky-600", "bg-violet-600", "bg-teal-600", "bg-pink-600", "bg-orange-600"];
function workerColor(name: string): string {
if (name === "planner") return "bg-amber-600"; // the intent generator, distinct
if (name === "mainagent") return "bg-primary";
const m = /#(\d+)/.exec(name);
const i = m ? (parseInt(m[1], 10) - 1) % workerColors.length : 0;
return workerColors[Math.max(0, i)];
}
const chip = (worker: string) =>
"mt-0.5 shrink-0 rounded px-1 text-[9px] font-medium text-white " + workerColor(worker);
// useInView latches true when the ref'd element first comes within `rootMargin` of
// the enclosing scroll viewport. Blocks that always show their full body (user
// bubbles, answers) use it to defer fetching that body until they're about to be
// seen — so opening a long thread doesn't fire a detail request for every off-
// screen step. Observes the ScrollArea viewport (falls back to eager load when
// IntersectionObserver is unavailable, e.g. SSR).
function useInView(rootMargin = "400px"): [React.RefObject<HTMLDivElement | null>, boolean] {
const ref = React.useRef<HTMLDivElement | null>(null);
const [inView, setInView] = React.useState(false);
React.useEffect(() => {
if (inView) return; // latch: once seen, stop observing
const el = ref.current;
if (!el) return;
if (typeof IntersectionObserver === "undefined") {
setInView(true);
return;
}
const root = el.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null;
const ob = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) setInView(true);
},
{ root, rootMargin },
);
ob.observe(el);
return () => ob.disconnect();
}, [inView, rootMargin]);
return [ref, inView];
}
// A tool group pairs a tool_use with its matching tool_result (by tool_use_id);
// a run of consecutive conversational steps (text/thinking/result) from the same
// agent is one "message".
type Group =
| { type: "user"; key: number; step: Activity; intent?: boolean }
| { type: "answer"; key: number; step: Activity }
| { type: "round"; key: number; label: string }
| { type: "tool"; key: number; worker: string; use?: Activity; result?: Activity }
| { type: "msg"; key: number; worker: string; steps: Activity[] }
| { type: "intercept"; key: number; step: Activity };
function groupSteps(steps: Activity[], chat: boolean): Group[] {
const out: Group[] = [];
const byToolId = new Map<string, Extract<Group, { type: "tool" }>>();
for (const s of steps) {
if (s.kind === "usage") continue; // live token-usage marker — not a rendered step
if (s.kind === "round") {
out.push({ type: "round", key: s.seq, label: s.summary || "" }); // planner round boundary (empty → localized fallback at render)
continue;
}
if (s.kind === "intercept_request") {
out.push({ type: "intercept", key: s.seq, step: s });
continue;
}
if (s.kind === "user" || s.kind === "intent") {
// human turn OR the LLM-generated intent leading a worker session — both are
// right-aligned bubbles; `intent` swaps the avatar to a non-human icon.
out.push({ type: "user", key: s.seq, step: s, intent: s.kind === "intent" });
continue;
}
// In a chat (main agent) the assistant's text/result IS the answer — render it
// full (markdown), never collapsed. thinking still folds into a compact block.
if (s.kind === "result" || (chat && s.kind === "text")) {
out.push({ type: "answer", key: s.seq, step: s });
continue;
}
if (s.kind === "tool_use") {
const g: Extract<Group, { type: "tool" }> = { type: "tool", key: s.seq, worker: s.worker, use: s };
if (s.tool_use_id) byToolId.set(s.tool_use_id, g);
out.push(g);
continue;
}
if (s.kind === "tool_result") {
// bind to its tool_use by id (NOT adjacency — tools can run in parallel)
const g = s.tool_use_id ? byToolId.get(s.tool_use_id) : undefined;
if (g && !g.result) g.result = s;
else out.push({ type: "tool", key: s.seq, worker: s.worker, result: s }); // orphan result
continue;
}
const last = out[out.length - 1];
if (last && last.type === "msg" && last.worker === s.worker) last.steps.push(s);
else out.push({ type: "msg", key: s.seq, worker: s.worker, steps: [s] });
}
return out;
}
// maps a step kind to its translation sub-key under transcript.kind.* (label resolved at render)
const kindKey = (k: string) => (k === "thinking" ? "thinking" : k === "result" ? "result" : "note");
function ActivityTime({ ts }: { ts: string }) {
const date = new Date(ts);
if (!ts || Number.isNaN(date.getTime())) return null;
return (
<time
dateTime={date.toISOString()}
title={date.toLocaleString("ko-KR")}
className="text-[10px] text-muted-foreground tabular-nums"
suppressHydrationWarning
>
{date.toLocaleString("ko-KR", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
})}
</time>
);
}
// toolInputText renders a tool_use input for display. For Bash it pulls the shell
// command out of the raw input JSON ({"command":…,"description":…}) so the UI shows
// the command itself instead of JSON; other tools fall back to the raw text.
function toolInputText(tool: string, raw: string): string {
if (tool !== "Bash") return raw;
// full input (expanded detail): parse and pull the command out.
try {
const o = JSON.parse(raw);
if (o && typeof o.command === "string") return o.command;
} catch {
// collapsed-row summaries are truncated to ~200 chars by the backend, so the
// JSON tail is cut off and JSON.parse fails — fall through and extract the
// "command" field by hand, tolerating the missing closing quote.
}
const m = raw.match(/"command"\s*:\s*"((?:\\.|[^"\\])*)/);
if (m) {
try {
// re-wrap the captured body and parse to unescape \n, \", \\, etc.
return JSON.parse('"' + m[1] + '"');
} catch {
// truncated mid-escape — unescape the common sequences best-effort.
return m[1].replace(/\\(["\\/nrt])/g, (_s, c) => (c === "n" ? "\n" : c === "r" ? "\r" : c === "t" ? "\t" : c));
}
}
return raw;
}
// InterceptCard renders an inline intercept_request approval card. The pending_id
// is extracted from the summary (format: "도구 X 승인 요청 (#N)") so buttons are
// available immediately without waiting for the detail load.
function InterceptCard({ step, getDetail }: { step: Activity; getDetail: (seq: number) => Promise<string> }) {
const t = useTranslations("transcript");
// extract pending_id from summary: "도구 Bash 승인 요청 (#42)"
const pendingId = React.useMemo(() => {
const m = /\(#(\d+)\)/.exec(step.summary);
return m ? parseInt(m[1], 10) : null;
}, [step.summary]);
const toolName = React.useMemo(() => {
const m = /도구\s+(\S+)\s+승인/.exec(step.summary);
return m ? m[1] : step.summary;
}, [step.summary]);
const [detail, setDetail] = React.useState<Record<string, unknown> | null>(null);
const [decided, setDecided] = React.useState<"allowed" | "denied" | "timeout" | null>(null);
const [deciding, setDeciding] = React.useState(false);
const [expanded, setExpanded] = React.useState(false);
const [pending, setPending] = React.useState<InterceptPending | null>(null);
const [statusError, setStatusError] = React.useState("");
const [retry, setRetry] = React.useState(0);
// Load persisted detail JSON + check the real current status from the backend
// so that a page refresh shows the already-decided state instead of re-offering buttons.
React.useEffect(() => {
let live = true;
getDetail(step.seq)
.then((raw) => {
if (!live || !raw) return;
try {
setDetail(JSON.parse(raw));
} catch {
/* ignore */
}
})
.catch(() => {
/* ignore */
});
return () => {
live = false;
};
}, [step.seq, getDetail]);
// biome-ignore lint/correctness/useExhaustiveDependencies: Retry explicitly reloads the same approval after a failed request.
React.useEffect(() => {
if (!pendingId) return;
let live = true;
api
.interceptGetOne(pendingId)
.then((p) => {
if (!live) return;
setPending(p);
setStatusError("");
if (p.status !== "pending") setDecided(p.status as "allowed" | "denied" | "timeout");
})
.catch((error) => {
if (live) setStatusError((error as Error).message || t("approval.loadError"));
});
return () => {
live = false;
};
}, [pendingId, retry]);
async function decide(decision: "allowed" | "denied") {
if (step.inherited || !pendingId || deciding) return;
setDeciding(true);
try {
await api.interceptDecide(pendingId, decision);
setDecided(decision);
toast.success(decision === "allowed" ? t("approval.allowedToast") : t("approval.deniedToast"));
} catch (e) {
toast.error((e as Error).message);
} finally {
setDeciding(false);
}
}
const inputStr = detail?.input ? JSON.stringify(detail.input).slice(0, 200) : null;
const row = pending
? { ...pending, status: decided || pending.status, conv_title: "", conv_agent_key: "", rule_name: "" }
: null;
return (
<div className="my-2 rounded-lg border border-amber-400/50 bg-amber-50/40 dark:bg-amber-950/15 p-3 text-xs">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-start gap-2 min-w-0">
<ShieldAlertIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-1.5 font-medium">
<span className="text-amber-700 dark:text-amber-400">{t("approval.request")}</span>
<code className="rounded bg-amber-100 dark:bg-amber-900/50 px-1 font-mono text-amber-800 dark:text-amber-300">
{toolName}
</code>
{pendingId && <span className="text-muted-foreground">#{pendingId}</span>}
</div>
{inputStr && <p className="font-mono text-muted-foreground truncate">{inputStr}</p>}
</div>
</div>
{step.inherited ? (
<Badge variant="outline">{t("approval.historyReadonly")}</Badge>
) : decided ? (
<span
className={
"shrink-0 rounded px-2 py-0.5 text-[11px] font-medium " +
(decided === "allowed"
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400"
: decided === "timeout"
? "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400"
: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400")
}
>
{decided === "allowed"
? t("approval.allowed")
: decided === "timeout"
? t("approval.timeout")
: t("approval.denied")}
</span>
) : (
<div className="flex shrink-0 gap-1.5">
<Button
size="sm"
className="h-6 px-2 text-xs"
disabled={deciding || !pendingId}
onClick={() => decide("allowed")}
>
<CheckIcon className="h-3 w-3" />
{t("approval.allow")}
</Button>
<Button
size="sm"
variant="destructive"
className="h-6 px-2 text-xs"
disabled={deciding || !pendingId}
onClick={() => decide("denied")}
>
<XIcon className="h-3 w-3" />
{t("approval.deny")}
</Button>
</div>
)}
</div>
{pendingId ? (
<Collapsible open={expanded} onOpenChange={setExpanded} className="mt-2 min-w-0">
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" aria-label={expanded ? t("approval.collapse") : t("approval.expand")}>
{expanded ? <ChevronDown data-icon="inline-start" /> : <ChevronRight data-icon="inline-start" />}
{expanded ? t("approval.collapse") : t("approval.expand")}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
{expanded && row ? (
<ApprovalDetail
row={row}
busy={deciding}
decide={(_id, decision) => decide(decision)}
revision={retry}
readOnly={Boolean(step.inherited)}
defaultExpanded
onResolved={setDecided}
/>
) : statusError ? (
<div className="flex flex-wrap items-center gap-2 p-3" role="alert">
<span>{statusError}</span>
<Button variant="outline" size="sm" onClick={() => setRetry((value) => value + 1)}>
{t("approval.retry")}
</Button>
</div>
) : (
<p className="p-3 text-muted-foreground">{t("approval.loading")}</p>
)}
</CollapsibleContent>
</Collapsible>
) : null}
</div>
);
}
// ToolBlock renders ONE tool call: the command and its result bound into a single
// row (collapsed shows the command + a status/result preview; expand shows the
// full input AND output together). Lazy-loads both details on expand.
function ToolBlock({
group,
getDetail,
showWorker,
focused = false,
}: {
group: Extract<Group, { type: "tool" }>;
getDetail: (seq: number) => Promise<string>;
showWorker?: boolean;
focused?: boolean;
}) {
const t = useTranslations("transcript");
const [open, setOpen] = React.useState(focused);
const targetRef = React.useRef<HTMLElement>(null);
const [detail, setDetail] = React.useState<string | null>(null);
// what we last loaded, keyed by the underlying step seqs. When the tool result
// arrives after we expanded mid-run (command only), this key changes and the
// effect below re-fetches — so the output shows up instead of being cached out.
const loadedKey = React.useRef<string | null>(null);
const { use, result } = group;
const toolName = use?.tool || result?.tool || t("tool.name");
const ToolIcon = toolName === "Bash" ? Terminal : Wrench;
const running = !result;
const ok = !!result && !result.is_error;
const statusTone = running
? "text-muted-foreground"
: ok
? "text-emerald-600 dark:text-emerald-400"
: "text-red-600 dark:text-red-400";
const rawCmd =
use && use.summary.startsWith(toolName) ? use.summary.slice(toolName.length).trimStart() : (use?.summary ?? "");
const cmd = toolInputText(toolName, rawCmd);
// status only — the full result lives behind the expand (output section), not previewed inline
const statusText = running ? t("tool.running") : ok ? "✓" : t("tool.failed");
// key over the seqs we'd load; changes when the result (or command) arrives.
const detailKey = `${use?.seq ?? ""}:${result?.seq ?? ""}`;
React.useEffect(() => {
if (!open || loadedKey.current === detailKey) return;
let live = true;
const segs: { label: string; seq: number; cmd?: boolean }[] = [];
if (use) segs.push({ label: t("tool.command"), seq: use.seq, cmd: true });
if (result) segs.push({ label: t("tool.output") + (result.is_error ? " ✕" : " ✓"), seq: result.seq });
void Promise.all(
segs.map((x) =>
getDetail(x.seq)
.then((d) => d || t("tool.empty"))
.catch(() => t("tool.loadFailed")),
),
).then((parts) => {
if (!live) return;
setDetail(
segs.map((x, i) => `【${x.label}】\n${x.cmd ? toolInputText(toolName, parts[i]) : parts[i]}`).join("\n\n"),
);
loadedKey.current = detailKey;
});
return () => {
live = false;
};
}, [open, detailKey, use, result, getDetail, toolName, t]);
const scrolledRef = React.useRef(false);
React.useEffect(() => {
scrolledRef.current = false;
if (focused) setOpen(true);
}, [focused]);
React.useEffect(() => {
if (!focused || !open || detail === null || scrolledRef.current) return;
const el = targetRef.current;
const viewport = el?.closest('[data-slot="scroll-area-viewport"]');
if (!el || !viewport) return;
scrolledRef.current = true;
const center = () => {
const rect = el.getBoundingClientRect();
const bounds = viewport.getBoundingClientRect();
viewport.scrollTop += rect.top + rect.height / 2 - bounds.top - bounds.height / 2;
};
// User bubbles load their full text lazily. Allow their initial layout to
// settle, but stop anchoring immediately if the user interacts or after 2s.
const observer = new ResizeObserver(center);
observer.observe(el.parentElement ?? el);
observer.observe(viewport);
const stop = () => observer.disconnect();
viewport.addEventListener("wheel", stop, { passive: true, once: true });
viewport.addEventListener("touchstart", stop, { passive: true, once: true });
viewport.addEventListener("pointerdown", stop, { once: true });
const frame = requestAnimationFrame(center);
const timer = setTimeout(stop, 2000);
return () => {
cancelAnimationFrame(frame);
clearTimeout(timer);
stop();
viewport.removeEventListener("wheel", stop);
viewport.removeEventListener("touchstart", stop);
viewport.removeEventListener("pointerdown", stop);
};
}, [focused, open, detail]);
function toggle() {
setOpen((o) => !o);
}
return (
<section
ref={targetRef}
aria-label={focused ? t("tool.pinned", { seq: use?.seq ?? "" }) : undefined}
className={focused ? "rounded-lg border-2 border-primary bg-primary/5 p-3 text-xs" : "text-xs"}
>
<button type="button" onClick={toggle} className="flex w-full items-start gap-2 py-1 text-left hover:bg-muted/40">
<span className="mt-0.5 text-muted-foreground">
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
</span>
<ToolIcon className={"mt-0.5 size-3.5 shrink-0 " + (running ? "text-sky-600 dark:text-sky-400" : statusTone)} />
{showWorker && <span className={chip(group.worker)}>{group.worker}</span>}
<span className="shrink-0 font-medium text-sky-600 dark:text-sky-400">{toolName}</span>
{cmd && <span className="min-w-0 flex-1 truncate font-mono text-muted-foreground">{cmd}</span>}
<span className={"ml-auto shrink-0 font-medium " + statusTone}>{statusText}</span>
</button>
{open && (
<pre className="ml-7 mb-1 max-h-72 overflow-auto whitespace-pre-wrap break-all rounded bg-muted/50 p-2 font-mono text-[11px] leading-relaxed">
{detail ?? t("loading")}
</pre>
)}
</section>
);
}
// MessageBlock renders a coalesced agent message (merged streaming text/thinking/
// result fragments into one block) so the conversation reads as messages, not rows.
function MessageBlock({
group,
getDetail,
showWorker,
}: {
group: Extract<Group, { type: "msg" }>;
getDetail: (seq: number) => Promise<string>;
showWorker?: boolean;
}) {
const t = useTranslations("transcript");
const [open, setOpen] = React.useState(false);
const [detail, setDetail] = React.useState<string | null>(null);
// like ToolBlock: keyed by the group's step seqs so streamed steps arriving
// after an early expand re-fetch instead of being cached out.
const loadedKey = React.useRef<string | null>(null);
const speak = group.steps.filter((s) => s.kind !== "thinking");
const hasThinking = group.steps.some((s) => s.kind === "thinking");
const isError = group.steps.some((s) => s.is_error);
const body = (speak.length ? speak : group.steps).map((s) => s.summary).join(" ") || "…";
const Icon = isError ? Flag : MessageSquare;
const tone = isError ? "text-red-600 dark:text-red-400" : "text-foreground";
const detailKey = group.steps.map((s) => s.seq).join(",");
React.useEffect(() => {
if (!open || loadedKey.current === detailKey) return;
let live = true;
void Promise.all(
group.steps.map((s) =>
getDetail(s.seq)
.then((d) => d || s.summary)
.catch(() => s.summary),
),
).then((parts) => {
if (!live) return;
setDetail(group.steps.map((s, i) => `【${t(`kind.${kindKey(s.kind)}`)}】\n${parts[i]}`).join("\n\n"));
loadedKey.current = detailKey;
});
return () => {
live = false;
};
}, [open, detailKey, group.steps, getDetail, t]);
function toggle() {
setOpen((o) => !o);
}
return (
<div className="text-xs">
<button type="button" onClick={toggle} className="flex w-full items-start gap-2 py-1 text-left hover:bg-muted/40">
<span className="mt-0.5 text-muted-foreground">
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
</span>
<Icon className={"mt-0.5 size-3.5 shrink-0 " + tone} />
{showWorker && <span className={chip(group.worker)}>{group.worker}</span>}
<span className={"min-w-0 flex-1 truncate " + tone}>
{body}
{hasThinking && <span className="ml-1 text-[10px] text-muted-foreground">· {t("withThinking")}</span>}
</span>
</button>
{open && (
<pre className="ml-7 mb-1 max-h-72 overflow-auto whitespace-pre-wrap break-all rounded bg-muted/50 p-2 font-mono text-[11px] leading-relaxed">
{detail ?? t("loading")}
</pre>
)}
</div>
);
}
// UserRow renders a right-aligned chat bubble: either a human turn (the message you
// sent the main agent) or, in a worker session, the LLM-generated intent that leads
// it (intent=true) — same bubble, but a target icon instead of the human avatar.
// summary is a truncated first line, so the full message is pulled from the detail
// and shown in full (bubble is whitespace-pre-wrap, so long/multi-line text wraps).
// fmtBytes renders a human file size for attachment chips.
function fmtBytes(n: number): string {
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(1)} KB`;
return `${n} B`;
}
type MsgAttachment = { name: string; path: string; size: number };
// parseUserBody splits a user turn's body into text + attachments. The backend stores
// Detail as JSON {text, attachments} when files were uploaded, else plain text — so we
// parse defensively and fall back to treating the whole body as text.
function parseUserBody(body: string): { text: string; attachments: MsgAttachment[] } {
if (body.startsWith("{")) {
try {
const p = JSON.parse(body);
if (p && Array.isArray(p.attachments)) {
return { text: typeof p.text === "string" ? p.text : "", attachments: p.attachments };
}
} catch {
/* plain text that merely starts with "{" */
}
}
return { text: body, attachments: [] };
}
function UserRow({
step,
intent,
getDetail,
}: {
step: Activity;
intent?: boolean;
getDetail: (seq: number) => Promise<string>;
}) {
const Icon = intent ? CrosshairIcon : UserIcon;
const [ref, inView] = useInView();
// Optimistic echoes carry their detail inline; persisted rows lazy-load it on scroll.
const inline = step.detail && step.detail.length > 0 ? step.detail : null;
const [full, setFull] = React.useState<string | null>(inline);
React.useEffect(() => {
if (inline) return; // already have the body (optimistic echo)
if (!inView) return; // fetch the full message only when the bubble nears view
let live = true;
getDetail(step.seq)
.then((d) => {
if (live) setFull(d || step.summary);
})
.catch(() => {
if (live) setFull(step.summary);
});
return () => {
live = false;
};
}, [inView, step.seq, getDetail, step.summary, inline]);
const { text, attachments } = parseUserBody(full ?? step.summary);
return (
<div ref={ref} className="mt-3 mb-2 flex min-w-0 justify-end gap-2">
<div className="flex min-w-0 max-w-[85%] flex-col items-end gap-1.5">
{attachments.length > 0 && (
<div className="flex min-w-0 max-w-full flex-wrap justify-end gap-1.5">
{attachments.map((a) => (
<div
key={a.path}
className="flex min-w-0 max-w-full items-center gap-1.5 rounded-md border bg-card px-2 py-1 text-xs shadow-sm"
title={a.path}
>
<PaperclipIcon className="size-3 shrink-0 text-primary" />
<span className="max-w-[180px] truncate font-medium">{a.name}</span>
<span className="shrink-0 text-muted-foreground">{fmtBytes(a.size)}</span>
</div>
))}
</div>
)}
{text && (
<div className="min-w-0 max-w-full whitespace-pre-wrap rounded-lg rounded-tr-sm bg-primary px-3 py-1.5 text-sm text-primary-foreground [overflow-wrap:anywhere]">
{text}
</div>
)}
<ActivityTime ts={step.ts} />
</div>
<div className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10">
<Icon className="size-3.5 text-primary" />
</div>
</div>
);
}
// AnswerBlock renders the agent's FINAL answer (kind="result") in full — never
// collapsed. The summary is a truncated first line, so the full text is pulled
// from the detail and shown inline.
function AnswerBlock({ step, getDetail }: { step: Activity; getDetail: (seq: number) => Promise<string> }) {
const [ref, inView] = useInView();
const [full, setFull] = React.useState<string | null>(null);
React.useEffect(() => {
if (!inView) return; // fetch the full answer only when it nears view
let live = true;
getDetail(step.seq)
.then((d) => {
if (live) setFull(d || step.summary);
})
.catch(() => {
if (live) setFull(step.summary);
});
return () => {
live = false;
};
}, [inView, step.seq, getDetail, step.summary]);
return (
<div ref={ref} className="mb-2 mt-1 flex min-w-0 flex-col gap-1">
<div
className={
"min-w-0 flex-1 break-words rounded-lg bg-muted px-3 py-2 " +
(step.is_error ? "text-sm text-red-600 dark:text-red-400" : "")
}
>
{step.is_error ? (
<span className="whitespace-pre-wrap">{full ?? step.summary}</span>
) : (
<Markdown text={full ?? step.summary} />
)}
</div>
<ActivityTime ts={step.ts} />
</div>
);
}
// ExecView renders an agent execution replay (planner / worker / main agent) in
// the compact, grouped, expand-to-detail format — thinking, tool calls/results,
// and (for the main agent) the human turns. Worker lane chips show only when the
// view actually mixes agents.
function ExecView({
activity,
taskId,
chat,
fetchDetail,
focusedSeq,
}: {
activity: Activity[];
taskId?: string;
chat?: boolean;
fetchDetail?: (seq: number) => Promise<string>;
focusedSeq?: number;
}) {
const t = useTranslations("transcript");
const showWorker = new Set(activity.map((a) => a.worker)).size > 1;
// default detail fetcher: the task-scoped activity endpoint. The chat page passes
// its own (conversation-scoped) fetcher instead.
const getDetail = React.useCallback(
(seq: number) => (fetchDetail ? fetchDetail(seq) : api.activityDetail(seq, taskId).then((r) => r.detail ?? "")),
[fetchDetail, taskId],
);
return (
<div className="flex flex-col">
{groupSteps(activity, !!chat).map((g) =>
g.type === "round" ? (
<div key={"r" + g.key} className="my-2 flex items-center gap-2 text-[10px] font-medium text-muted-foreground">
<span className="h-px flex-1 bg-border" />
{g.label || t("newRound")}
<span className="h-px flex-1 bg-border" />
</div>
) : g.type === "user" ? (
<UserRow key={"u" + g.key} step={g.step} intent={g.intent} getDetail={getDetail} />
) : g.type === "answer" ? (
<AnswerBlock key={"a" + g.key} step={g.step} getDetail={getDetail} />
) : g.type === "tool" ? (
<ToolBlock
key={"t" + g.key}
group={g}
getDetail={getDetail}
showWorker={showWorker}
focused={focusedSeq != null && g.use?.seq === focusedSeq}
/>
) : g.type === "intercept" ? (
<InterceptCard key={"ic" + g.key} step={g.step} getDetail={getDetail} />
) : (
<MessageBlock key={"m" + g.key} group={g} getDetail={getDetail} showWorker={showWorker} />
),
)}
</div>
);
}
// Transcript renders a session's activity as a compact grouped execution replay:
// human turns, thinking, tool calls (command+result paired), and messages — for
// the main agent (interactive) and worker/planner (read-only) alike.
export function Transcript({
activity,
live,
taskId,
chat,
fetchDetail,
focusedSeq,
}: {
activity: Activity[];
live?: boolean;
taskId?: string;
chat?: boolean;
fetchDetail?: (seq: number) => Promise<string>;
focusedSeq?: number;
}) {
const t = useTranslations("transcript");
const transcriptRef = React.useRef<HTMLDivElement>(null);
const [focusPadding, setFocusPadding] = React.useState(0);
React.useLayoutEffect(() => {
if (focusedSeq == null) {
setFocusPadding(0);
return;
}
const viewport = transcriptRef.current?.closest('[data-slot="scroll-area-viewport"]');
if (!viewport) return;
const measure = () => setFocusPadding(viewport.clientHeight / 2);
measure();
const observer = new ResizeObserver(measure);
observer.observe(viewport);
return () => observer.disconnect();
}, [focusedSeq]);
return (
<div
ref={transcriptRef}
className="flex flex-col gap-1"
style={focusPadding ? { paddingBlock: focusPadding } : undefined}
>
<ExecView activity={activity} taskId={taskId} chat={chat} fetchDetail={fetchDetail} focusedSeq={focusedSeq} />
{live && (
<div className="flex items-center gap-2 pl-2 pt-1 text-xs text-muted-foreground">
<span className="flex gap-1">
<span className="size-1.5 animate-bounce rounded-full bg-blue-500 [animation-delay:-0.3s]" />
<span className="size-1.5 animate-bounce rounded-full bg-blue-500 [animation-delay:-0.15s]" />
<span className="size-1.5 animate-bounce rounded-full bg-blue-500" />
</span>
{t("streaming")}
</div>
)}
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client"
import * as React from "react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col", className)}
{...props}
/>
)
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
className={cn(
"h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+199
View File
@@ -0,0 +1,199 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
+76
View File
@@ -0,0 +1,76 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_minmax(0,1fr)] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"min-w-0 font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"min-w-0 text-sm text-balance text-muted-foreground [overflow-wrap:anywhere] md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
+11
View File
@@ -0,0 +1,11 @@
"use client"
import { AspectRatio as AspectRatioPrimitive } from "radix-ui"
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }
+204
View File
@@ -0,0 +1,204 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
const attachmentVariants = cva(
"group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed",
{
variants: {
size: {
default:
"gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2",
sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5",
xs: "gap-1.5 rounded-lg text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1",
},
orientation: {
horizontal: "min-w-40 items-center",
vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30",
},
},
}
)
function Attachment({
className,
state = "done",
size = "default",
orientation = "horizontal",
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof attachmentVariants> & {
state?: "idle" | "uploading" | "processing" | "error" | "done"
}) {
return (
<div
data-slot="attachment"
data-state={state}
data-size={size}
data-orientation={orientation}
className={cn(attachmentVariants({ size, orientation }), className)}
{...props}
/>
)
}
const attachmentMediaVariants = cva(
"relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5",
{
variants: {
variant: {
icon: "",
image:
"opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover",
},
},
defaultVariants: {
variant: "icon",
},
}
)
function AttachmentMedia({
className,
variant = "icon",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof attachmentMediaVariants>) {
return (
<div
data-slot="attachment-media"
data-variant={variant}
className={cn(attachmentMediaVariants({ variant }), className)}
{...props}
/>
)
}
function AttachmentContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-content"
className={cn(
"max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1",
className
)}
{...props}
/>
)
}
function AttachmentTitle({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-title"
className={cn(
"block max-w-full min-w-0 truncate font-medium group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer",
className
)}
{...props}
/>
)
}
function AttachmentDescription({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-description"
className={cn(
"mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80",
"max-w-full",
className
)}
{...props}
/>
)
}
function AttachmentActions({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-actions"
className={cn(
"relative z-20 flex shrink-0 items-center group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 group-data-[orientation=vertical]/attachment:gap-1",
className
)}
{...props}
/>
)
}
function AttachmentAction({
className,
variant,
size = "icon-xs",
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="attachment-action"
variant={variant ?? "ghost"}
size={size}
className={cn(className)}
{...props}
/>
)
}
function AttachmentTrigger({
className,
asChild = false,
type,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="attachment-trigger"
type={asChild ? undefined : (type ?? "button")}
className={cn("absolute inset-0 z-10 outline-none", className)}
{...props}
/>
)
}
function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-group"
className={cn(
"flex min-w-0 scroll-fade-x snap-x snap-mandatory scroll-px-1 scrollbar-none gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start",
className
)}
{...props}
/>
)
}
export {
Attachment,
AttachmentGroup,
AttachmentMedia,
AttachmentContent,
AttachmentTitle,
AttachmentDescription,
AttachmentActions,
AttachmentAction,
AttachmentTrigger,
}
+112
View File
@@ -0,0 +1,112 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}
+49
View File
@@ -0,0 +1,49 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

Some files were not shown because too many files have changed in this diff Show More