feat: expand intelligent investigation workspace
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
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" />;
|
||||
}
|
||||
Reference in New Issue
Block a user