Files
next-tjwater-frontend/src/features/agent/components/agent-panel-controls.tsx
T
jiang c0e4231b83
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s
feat: expand intelligent investigation workspace
2026-10-30 17:30:19 +08:00

347 lines
14 KiB
TypeScript

import type { AgentInputDelivery } from "../api/client";
import type { AgentApprovalMode, AgentModelOption } from "../types";
import { AudioLines, CheckCircle2, ChevronDown, Shield, ShieldCheck, Sparkles, Zap } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from "@/shared/ui/dropdown-menu";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import type { AgentCommandPanelPresentation } from "./agent-command-panel";
const VOICE_WAVE_BARS = [
{ height: 5, duration: 0.72, delay: 0.08 },
{ height: 10, duration: 0.58, delay: 0 },
{ height: 15, duration: 0.66, delay: 0.12 },
{ height: 9, duration: 0.62, delay: 0.04 },
{ height: 4, duration: 0.7, delay: 0.16 }
] as const;
export function VoiceInputButton({
isListening,
disabled,
onClick
}: {
isListening: boolean;
disabled: boolean;
onClick: () => void;
}) {
const reduceMotion = useReducedMotion();
const label = isListening ? "停止语音输入" : "语音输入";
return (
<motion.button
type="button"
aria-label={label}
aria-pressed={isListening}
title={label}
className={cn(
"agent-panel-voice-input group relative grid size-10 shrink-0 place-items-center overflow-visible rounded-full border-0",
"bg-transparent shadow-none",
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-0",
"disabled:cursor-not-allowed disabled:opacity-45",
isListening
? "status-tone-danger bg-[var(--status-soft)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:brightness-95"
: "text-slate-600 [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)] active:bg-[var(--action-soft-pressed)]"
)}
disabled={disabled}
onClick={onClick}
>
{isListening ? (
<motion.span
data-slot="voice-input-pulse"
className="pointer-events-none absolute inset-0 rounded-full bg-[var(--status-mark)] opacity-20"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.9 }}
animate={
reduceMotion
? { opacity: 0.38, scale: 1 }
: { opacity: [0, 0.4, 0], scale: [0.9, 1, 1.28] }
}
transition={
reduceMotion
? { duration: 0.16 }
: {
duration: 1.7,
ease: "easeOut",
repeat: Infinity,
times: [0, 0.12, 1]
}
}
/>
) : null}
<span className="relative grid h-5 w-5 place-items-center overflow-hidden">
<AnimatePresence initial={false} mode="wait">
{isListening ? (
<motion.span
key="voice-wave"
className="flex h-4 items-center gap-0.5"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.72 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.72 }}
transition={{ duration: 0.16, ease: "easeOut" }}
>
{VOICE_WAVE_BARS.map((bar) => (
<motion.span
key={bar.height}
className="w-px rounded-full bg-[var(--status-mark)]"
style={{ height: bar.height }}
animate={
reduceMotion ? { scaleY: 0.72 } : { scaleY: [0.38, 1, 0.52, 0.82, 0.38] }
}
transition={
reduceMotion
? { duration: 0.16 }
: {
duration: bar.duration,
delay: bar.delay,
ease: "easeInOut",
repeat: Infinity
}
}
aria-hidden="true"
/>
))}
</motion.span>
) : (
<motion.span
key="voice-idle"
className="grid place-items-center"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.72, y: 2 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.72, y: -2 }}
transition={{ duration: 0.16, ease: "easeOut" }}
>
<AudioLines
strokeWidth={2}
className="size-4 transition-transform duration-200 group-hover:scale-105"
/>
</motion.span>
)}
</AnimatePresence>
</span>
</motion.button>
);
}
const AGENT_SELECT_ITEM_CLASS_NAME =
"rounded-lg border border-transparent px-2.5 py-2 focus:border-[var(--action-border-hover)] focus:bg-[var(--action-selection-soft-hover)] data-[highlighted]:border-[var(--action-border-hover)] data-[highlighted]:bg-[var(--action-selection-soft-hover)] motion-reduce:transition-none";
function AgentToolbarCaret() {
return <ChevronDown className="agent-panel-toolbar-caret size-3 shrink-0 opacity-50" aria-hidden="true" />;
}
export function InputDeliverySelect({ presentation, label, value, onValueChange }: {
presentation: AgentCommandPanelPresentation;
label: string;
value: AgentInputDelivery;
onValueChange?: (delivery: AgentInputDelivery) => void;
}) {
return <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="sm" aria-label={label}
title={value === "queue" ? "当前任务结束后处理" : "在下一步补充当前任务"}
className="agent-panel-toolbar-trigger h-10 shrink-0 gap-1.5 px-2 text-sm font-normal active:scale-95 motion-reduce:transform-none"
disabled={!onValueChange}>
{value === "queue" ? "排队" : "引导"}
<AgentToolbarCaret />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent data-agent-presentation={presentation} side="top" align="end" sideOffset={6}
collisionPadding={12} className="agent-panel-overlay agent-panel-control w-44 rounded-xl p-1.5 shadow-lg">
<DropdownMenuRadioGroup aria-label={label} value={value} onValueChange={(delivery) => {
if (delivery === "steer" || delivery === "queue") onValueChange?.(delivery);
}}>
{([ ["steer", "引导当前任务"], ["queue", "排队处理"] ] as const).map(([delivery, text]) => (
<DropdownMenuRadioItem key={delivery} value={delivery}
className={cn(AGENT_SELECT_ITEM_CLASS_NAME, "min-h-10 pl-8 pr-2.5 text-sm",
value === delivery && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]")}>
{text}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>;
}
export function ApprovalModeSelect({
presentation,
value,
disabled,
onValueChange
}: {
presentation: AgentCommandPanelPresentation;
value: AgentApprovalMode;
disabled: boolean;
onValueChange?: (mode: AgentApprovalMode) => void;
}) {
const requestApproval = value === "request";
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="agent-panel-toolbar-trigger h-10 shrink-0 gap-1.5 border-0 bg-transparent px-1.5 text-sm text-slate-600 shadow-none data-[state=open]:bg-[var(--action-soft)] data-[state=open]:text-[var(--action-blue-hover)] focus-visible:ring-1 focus-visible:ring-offset-0"
disabled={disabled || !onValueChange}
aria-label="权限批准模式"
title={requestApproval ? "请求批准" : "始终允许"}
>
{requestApproval ? (
<Shield className="size-4" aria-hidden="true" />
) : (
<ShieldCheck className="size-4" aria-hidden="true" />
)}
<span>{requestApproval ? "请求批准" : "始终允许"}</span>
<AgentToolbarCaret />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
data-agent-presentation={presentation}
side="top"
align="start"
sideOffset={8}
className="agent-panel-overlay agent-panel-control w-[220px] rounded-xl p-2 shadow-lg"
>
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-[var(--ink-muted)]">
权限批准模式
</DropdownMenuLabel>
<DropdownMenuItem
className={cn(
AGENT_SELECT_ITEM_CLASS_NAME,
"items-start",
requestApproval && "border-[var(--surface-border)] bg-[var(--action-soft)]"
)}
onSelect={() => onValueChange?.("request")}
>
<Shield className="mt-0.5 text-[var(--action-blue-hover)]" aria-hidden="true" />
<span>
<span className="block text-sm font-semibold text-slate-800">请求批准</span>
<span className="block text-xs leading-5 text-[var(--ink-muted)]">需要审批的请求逐次确认;只读白名单直接执行</span>
</span>
{requestApproval ? (
<CheckCircle2 className="ml-auto mt-0.5 text-[var(--action-blue-hover)]" aria-hidden="true" />
) : null}
</DropdownMenuItem>
<DropdownMenuItem
className={cn(
AGENT_SELECT_ITEM_CLASS_NAME,
"mt-1 items-start",
!requestApproval &&
"status-tone-success border-[var(--status-border)] bg-[var(--status-soft)]"
)}
onSelect={() => onValueChange?.("always")}
>
<ShieldCheck
className="status-tone-success mt-0.5 text-[var(--status-foreground)]"
aria-hidden="true"
/>
<span>
<span className="block text-sm font-semibold text-slate-800">始终允许</span>
<span className="block text-xs leading-5 text-[var(--ink-muted)]">自动批准本轮工具权限请求,不改变业务权限或安全限制</span>
</span>
{!requestApproval ? (
<CheckCircle2
className="status-tone-success ml-auto mt-0.5 text-[var(--status-foreground)]"
aria-hidden="true"
/>
) : null}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function AgentModelSelect({
presentation,
models,
value,
onValueChange
}: {
presentation: AgentCommandPanelPresentation;
models: AgentModelOption[];
value: string;
onValueChange?: (model: string) => void;
}) {
const selectedModel = models.find((model) => model.id === value);
if (!models.length) {
return (
<span className="shrink-0 rounded-md px-2 py-1 text-sm text-[var(--ink-muted)]">模型</span>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="agent-panel-toolbar-trigger h-10 w-11 shrink-0 justify-between gap-1 border-0 bg-transparent px-1 text-sm text-slate-600 shadow-none data-[state=open]:bg-[var(--action-soft)] data-[state=open]:text-[var(--action-blue-hover)] focus-visible:ring-1 focus-visible:ring-offset-0 @[400px]/agent-composer:w-auto @[400px]/agent-composer:max-w-40 @[400px]/agent-composer:gap-1.5 @[400px]/agent-composer:px-1.5"
disabled={!onValueChange}
aria-label="选择 Agent 模型"
aria-description={`当前模型:${selectedModel?.label ?? "模型"}`}
title={selectedModel?.label ?? "模型"}
>
<span className="flex min-w-0 flex-1 items-center gap-1">
<ModelIcon model={selectedModel} />
<span className="hidden truncate @[400px]/agent-composer:inline">{selectedModel?.label ?? "模型"}</span>
</span>
<AgentToolbarCaret />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
data-agent-presentation={presentation}
side="top"
align="end"
sideOffset={8}
className="agent-panel-overlay agent-panel-control w-[230px] rounded-xl p-2 shadow-lg"
>
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-[var(--ink-muted)]">
模型选择
</DropdownMenuLabel>
<div className="space-y-1">
{models.map((model) => {
const selected = model.id === selectedModel?.id;
return (
<DropdownMenuItem
key={model.id}
className={cn(
AGENT_SELECT_ITEM_CLASS_NAME,
selected && "border-[var(--surface-border)] bg-[var(--action-soft)]"
)}
onSelect={() => onValueChange?.(model.id)}
>
<span className="ml-0.5 shrink-0 pt-0.5">
<ModelIcon model={model} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-slate-800">
{model.label}
</span>
<span className="block truncate text-xs leading-4 text-[var(--ink-muted)]">
{model.description}
</span>
</span>
{selected ? (
<CheckCircle2 size={15} className={presentation === "desktop-dock" ? "text-[var(--action-blue-hover)]" : "text-[var(--action-blue-hover)]"} aria-hidden="true" />
) : null}
</DropdownMenuItem>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
function ModelIcon({ model }: { model?: AgentModelOption }) {
const Icon = model?.icon === "bolt" ? Zap : Sparkles;
return <Icon className="size-4" aria-hidden="true" />;
}