Files
UHD_Media_Tool/apps/web/app/components/agentui/tool-approval.tsx
T
rootandClaude Opus 5.5 52402864d6 style(copy): punctuation rules and site-wide fix with a CI check (QA U11)
- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing,
  full-width parens around Chinese, single ellipsis, 「」 quotes, period rules)
- scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX
  text (not comments, types, keys, match/compare strings); --fix, --strict,
  --by-file; wired into deno task ci as check:copy in warn mode
- --fix applied to web, server and contracts; files other lanes were editing
  (subscriptions, sites, browser, agent, auth/account, inventory) left for them
- tests updated for the changed messages

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 20:09:29 +08:00

185 lines
7.2 KiB
TypeScript

// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/tool-approval.tsx
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
// 改造:状态徽章 amber/blue/emerald/rose → warning/中性/success/destructive;去掉 "Always allow"(本项目的持续授权
// 只在设置页按策略版本发放,不在对话里一键放行);新增 `rejected` 醒目态:服务端拒绝了一次工具调用时,
// 整卡换 destructive 材质、role=alert、给出拒绝原因与授权说明;新增 `badges` 槽(scope/风险级别);参数区用 Disclosure。
import { Check, ChevronDown, CircleAlert, LoaderCircle, ShieldAlert, ShieldCheck, X } from "lucide-react";
import { type ReactNode, useId, useState } from "react";
import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils.ts";
import { CHEVRON_CLASS, Disclosure } from "./disclosure.tsx";
export type ToolApprovalStatus =
| "pending"
| "approving"
| "approved"
| "denied"
| "rejected"
| "running"
| "complete"
| "error";
export interface ToolApprovalParameter {
id: string;
label: ReactNode;
value: ReactNode;
}
const COPY: Record<ToolApprovalStatus, string> = {
pending: "待授权",
approving: "提交中",
approved: "已批准",
denied: "已拒绝",
rejected: "服务端拒绝",
running: "执行中",
complete: "已完成",
error: "失败",
};
const BADGE: Record<ToolApprovalStatus, string> = {
pending: "border-warning/30 bg-warning/10 text-warning",
approving: "border-border bg-foreground/[0.04] text-muted-foreground",
running: "border-border bg-foreground/[0.04] text-muted-foreground",
approved: "border-success/30 bg-success/10 text-success",
complete: "border-success/30 bg-success/10 text-success",
denied: "border-destructive/30 bg-destructive/10 text-destructive",
rejected: "border-destructive/40 bg-destructive/15 text-destructive",
error: "border-destructive/30 bg-destructive/10 text-destructive",
};
export interface ToolApprovalProps {
tool: ReactNode;
title?: ReactNode;
description?: ReactNode;
parameters?: ToolApprovalParameter[];
status?: ToolApprovalStatus;
badges?: ReactNode;
/** rejected 态:拒绝原因(服务端给出) */
reason?: ReactNode;
/** rejected 态:为什么会被拒绝、怎样才能放行 */
explanation?: ReactNode;
defaultOpen?: boolean;
onApprove?: () => void;
onDeny?: () => void;
/** 为什么按钮不可用(如非管理员会话) */
actionsDisabledReason?: string;
className?: string;
}
export function ToolApproval({
tool,
title = "允许执行这个工具?",
description,
parameters = [],
status = "pending",
badges,
reason,
explanation,
defaultOpen = false,
onApprove,
onDeny,
actionsDisabledReason,
className,
}: ToolApprovalProps) {
const id = useId();
const [open, setOpen] = useState(defaultOpen);
const busy = status === "approving" || status === "running";
const rejected = status === "rejected";
return (
<div
data-state={status}
aria-busy={busy}
role={rejected ? "alert" : undefined}
className={cn(
"w-full min-w-0 overflow-hidden rounded-xl border text-sm",
rejected ? "border-destructive/50 bg-destructive/10" : "border-border/70 bg-foreground/[0.02]",
className,
)}
>
<div className="flex items-start gap-3 p-3">
<span
aria-hidden
className={cn(
"mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg border",
rejected
? "border-destructive/40 bg-destructive/15 text-destructive"
: "border-border/60 text-muted-foreground",
status === "error" && "text-destructive",
)}
>
{busy ? (
<LoaderCircle className="size-4 animate-spin motion-reduce:animate-none" />
) : rejected ? (
<ShieldAlert className="size-4" />
) : status === "error" ? (
<CircleAlert className="size-4" />
) : status === "denied" ? (
<X className="size-4" />
) : status === "approved" || status === "complete" ? (
<Check className="size-4" />
) : (
<ShieldCheck className="size-4" />
)}
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-start justify-between gap-x-3 gap-y-1">
<div className="min-w-0">
<div className={cn("font-medium", rejected ? "text-destructive" : "text-foreground")}>{title}</div>
<div className="mt-0.5 font-mono text-meta break-all text-muted-foreground">{tool}</div>
</div>
<span className={cn("shrink-0 rounded-full border px-2 py-0.5 text-label font-medium", BADGE[status])}>
{COPY[status]}
</span>
</div>
{badges ? <div className="mt-1.5 flex flex-wrap items-center gap-1">{badges}</div> : null}
{rejected && reason ? (
<p className="mt-2 text-caption font-medium break-words text-destructive">原因:{reason}</p>
) : null}
{description ? <p className="mt-1.5 text-caption leading-5 text-muted-foreground">{description}</p> : null}
{rejected && explanation ? (
<p className="mt-1.5 text-meta leading-5 text-muted-foreground">{explanation}</p>
) : null}
{parameters.length ? (
<button
type="button"
aria-expanded={open}
aria-controls={id}
onClick={() => setOpen(!open)}
className="mt-2 inline-flex cursor-pointer items-center gap-1 rounded-md text-meta font-medium text-muted-foreground outline-none transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{open ? "收起详情" : "查看详情"}
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
</button>
) : null}
</div>
</div>
{parameters.length ? (
<Disclosure id={id} open={open}>
<dl className="mx-3 mb-3 grid gap-1.5 rounded-lg border border-border/50 p-2.5">
{parameters.map((p) => (
<div key={p.id} className="grid grid-cols-[minmax(0,7rem)_minmax(0,1fr)] items-start gap-3 text-meta">
<dt className="text-muted-foreground">{p.label}</dt>
<dd className="min-w-0 font-mono break-all text-foreground/85">{p.value}</dd>
</div>
))}
</dl>
</Disclosure>
) : null}
{status === "pending" && (onApprove || onDeny) ? (
<div className="flex flex-wrap items-center gap-2 border-t border-border/60 px-3 py-2.5">
<Button size="sm" onClick={onApprove} disabled={!!actionsDisabledReason || !onApprove}>
批准
</Button>
<Button size="sm" variant="ghost" onClick={onDeny} disabled={!!actionsDisabledReason || !onDeny}>
拒绝
</Button>
{actionsDisabledReason ? (
<span className="text-meta text-muted-foreground">{actionsDisabledReason}</span>
) : null}
</div>
) : null}
</div>
);
}