- 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]>
185 lines
7.2 KiB
TypeScript
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>
|
|
);
|
|
}
|