Files
TJWaterFrontend_Refine/src/components/chat/AgentComposer.tsx
T

604 lines
24 KiB
TypeScript

"use client";
import Image from "next/image";
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
Box,
Chip,
Collapse,
FormControl,
IconButton,
MenuItem,
Paper,
Select,
Stack,
TextField,
Typography,
alpha,
useTheme,
} from "@mui/material";
import SendRounded from "@mui/icons-material/SendRounded";
import StopRounded from "@mui/icons-material/StopRounded";
import MicRounded from "@mui/icons-material/MicRounded";
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
import BoltRounded from "@mui/icons-material/BoltRounded";
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
import AdminPanelSettingsRounded from "@mui/icons-material/AdminPanelSettingsRounded";
import WarningAmberRounded from "@mui/icons-material/WarningAmberRounded";
import type { AgentRuntimeState } from "@/lib/agentRuntime";
import type { AgentModelOption } from "@/lib/chatModels";
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
export type AgentComposerHandle = {
focus: () => void;
clear: () => void;
append: (text: string) => void;
setValue: (value: string) => void;
getValue: () => string;
};
type AgentComposerProps = {
isHydrating?: boolean;
runtimeState?: AgentRuntimeState;
isStreaming: boolean;
isListening: boolean;
isSttSupported: boolean;
presets: string[];
onSend: (prompt: string) => void;
onAbort: () => void;
onStartListening: () => void;
onStopListening: () => void;
modelOptions: AgentModelOption[];
selectedModel?: AgentModel;
onModelChange: (model: AgentModel) => void;
approvalMode: AgentApprovalMode;
onApprovalModeChange: (mode: AgentApprovalMode) => void;
};
const approvalModeOptions: ReadonlyArray<{
value: AgentApprovalMode;
label: string;
description: string;
icon: React.ElementType;
}> = [
{
value: "request",
label: "请求批准",
description: "白名单外的工具权限逐次确认",
icon: VerifiedUserRounded,
},
{
value: "auto",
label: "自动批准",
description: "低风险自动批准,其余仍需确认",
icon: AdminPanelSettingsRounded,
},
{
value: "always",
label: "始终允许",
description: "除明确禁止项外自动放行",
icon: WarningAmberRounded,
},
];
const getApprovalModeOption = (value: AgentApprovalMode) =>
approvalModeOptions.find((option) => option.value === value) ?? approvalModeOptions[0];
const renderModelIcon = (
icon: AgentModelOption["icon"] | undefined,
props?: React.ComponentProps<typeof BoltRounded>,
) =>
icon === "bolt" ? (
<BoltRounded {...props} />
) : (
<AutoAwesomeRounded {...props} />
);
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
isHydrating = false,
runtimeState = "ready",
isStreaming,
isListening,
isSttSupported,
presets,
onSend,
onAbort,
onStartListening,
onStopListening,
modelOptions,
selectedModel,
onModelChange,
approvalMode,
onApprovalModeChange,
}, ref) {
const theme = useTheme();
const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
const [input, setInput] = React.useState("");
const [isPresetOpen, setIsPresetOpen] = React.useState(false);
const isRuntimeReady = runtimeState === "ready";
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating && isRuntimeReady;
const placeholder = isHydrating
? "正在加载对话记录..."
: runtimeState === "checking"
? "正在连接 Agent 服务..."
: runtimeState === "models_unavailable"
? "模型尚未加载,暂时无法发送消息"
: runtimeState === "unavailable"
? "Agent 服务未就绪,暂时无法发送消息"
: "描述你的分析目标,或点击上方指令库...";
const selectedModelOption = modelOptions.find((model) => model.id === selectedModel);
const selectedApprovalModeOption = getApprovalModeOption(approvalMode);
React.useImperativeHandle(
ref,
() => ({
focus: () => inputRef.current?.focus(),
clear: () => setInput(""),
append: (text: string) => setInput((prev) => prev + text),
setValue: (value: string) => setInput(value),
getValue: () => input,
}),
[input],
);
const handleSend = React.useCallback(() => {
const prompt = input.trim();
if (!prompt || isStreaming || isHydrating || !isRuntimeReady) return;
setInput("");
onSend(prompt);
}, [input, isHydrating, isRuntimeReady, isStreaming, onSend]);
return (
<Box sx={{ px: 2, pb: 2, pt: 1, zIndex: 10 }}>
<Paper
elevation={isPresetOpen ? 4 : 0}
sx={{
mb: 1.5,
px: 1.5,
py: 1,
borderRadius: 4,
bgcolor: alpha("#fff", 0.6),
border: `1px solid ${alpha("#fff", 0.5)}`,
backdropFilter: "blur(24px)",
boxShadow: isPresetOpen ? `0 -8px 24px ${alpha("#00acc1", 0.1)}` : "none",
transition: "all 0.3s ease",
}}
>
<Stack direction="row" spacing={1} alignItems="center">
<AutoAwesomeRounded
titleAccess="快捷指令图标"
sx={{ fontSize: 18, color: "#00acc1", flexShrink: 0 }}
/>
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
管网分析快捷指令
</Typography>
<Box sx={{ flex: 1 }} />
<IconButton
size="small"
onClick={() => setIsPresetOpen((value) => !value)}
aria-label={isPresetOpen ? "收起常用管网任务" : "展开常用管网任务"}
sx={{ width: 28, height: 28, color: "text.secondary", bgcolor: alpha("#fff", 0.5) }}
>
{isPresetOpen ? (
<KeyboardArrowDownRounded fontSize="small" />
) : (
<KeyboardArrowUpRounded fontSize="small" />
)}
</IconButton>
</Stack>
<Collapse in={isPresetOpen} timeout="auto" unmountOnExit>
<Box sx={{ mt: 1.5, mb: 0.5, pb: 1 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{presets.map((prompt) => (
<Chip
key={prompt}
label={prompt.replace(/[。.]$/, "")}
size="medium"
clickable
disabled={!isRuntimeReady || isHydrating || isStreaming}
onClick={() => {
setInput(prompt);
setIsPresetOpen(false);
window.setTimeout(() => {
inputRef.current?.focus();
}, 0);
}}
sx={{
height: 32,
borderRadius: "16px",
bgcolor: alpha("#fff", 0.7),
border: `1px solid ${alpha("#00acc1", 0.15)}`,
color: "text.primary",
fontWeight: 600,
fontSize: '0.85rem',
boxShadow: `0 2px 6px ${alpha("#000", 0.03)}`,
backdropFilter: "blur(10px)",
"&:hover": {
bgcolor: alpha("#fff", 0.95),
boxShadow: `0 4px 10px ${alpha("#00acc1", 0.2)}`,
borderColor: alpha("#00acc1", 0.4),
color: "#00acc1"
}
}}
/>
))}
</Box>
</Box>
</Collapse>
</Paper>
<motion.div initial={{ y: 20, opacity: 0 }} animate={{ y: 0, opacity: 1 }}>
<Paper
elevation={12}
sx={{
display: "flex",
flexDirection: "column",
p: 1.5,
borderRadius: 5,
bgcolor: alpha("#ffffff", 0.75),
backdropFilter: "blur(40px)",
border: `1px solid ${alpha("#ffffff", 0.9)}`,
boxShadow: `0 16px 40px ${alpha("#000", 0.1)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`,
}}
>
<TextField
inputRef={inputRef}
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSend();
}
}}
placeholder={placeholder}
fullWidth
multiline
maxRows={5}
variant="standard"
disabled={isHydrating || !isRuntimeReady}
InputProps={{
disableUnderline: true,
sx: { px: 1, py: 0.5, fontSize: "1rem", lineHeight: 1.6, fontWeight: 500, color: "text.primary" },
}}
/>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mt: 2 }}>
<Stack direction="row" spacing={0.5} alignItems="center">
<FormControl size="small" sx={{ minWidth: 128 }}>
<Select
value={approvalMode}
onChange={(event) =>
onApprovalModeChange(event.target.value as AgentApprovalMode)
}
disabled={isHydrating || isStreaming || !isRuntimeReady}
aria-label="权限批准模式"
renderValue={() => {
const SelectedApprovalIcon = selectedApprovalModeOption.icon;
return (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.45 }}>
<SelectedApprovalIcon
sx={{
fontSize: 18,
color:
selectedApprovalModeOption.value === "always"
? "warning.main"
: "inherit",
}}
/>
<Typography sx={{ fontSize: "0.75rem", fontWeight: 600, color: "inherit" }}>
{selectedApprovalModeOption.label}
</Typography>
</Box>
);
}}
MenuProps={{
anchorOrigin: { vertical: "top", horizontal: "left" },
transformOrigin: { vertical: "bottom", horizontal: "left" },
sx: { zIndex: (theme) => theme.zIndex.modal + 110 },
PaperProps: {
sx: {
mb: 1.5,
width: 248,
borderRadius: 4,
bgcolor: alpha("#fff", 0.9),
backdropFilter: "blur(24px)",
border: `1px solid ${alpha("#fff", 0.9)}`,
boxShadow: `0 -12px 40px ${alpha("#000", 0.08)}`,
"& .MuiList-root": { p: 1 },
"& .MuiMenuItem-root": {
px: 1.5,
py: 1.2,
mb: 0.5,
borderRadius: 3,
alignItems: "flex-start",
"&:last-child": { mb: 0 },
"&.Mui-selected": {
bgcolor: alpha("#00acc1", 0.08),
"&:hover": { bgcolor: alpha("#00acc1", 0.12) },
"& .title": { color: "#00838f" },
"& .icon": { color: "#00acc1" },
"& .always-icon": { color: "warning.main" },
},
},
},
},
}}
sx={{
height: 36,
borderRadius: "18px",
bgcolor: alpha("#fff", 0.6),
color: "text.secondary",
".MuiOutlinedInput-notchedOutline": { border: "none" },
".MuiSelect-select": {
py: 0,
pl: 1,
pr: "28px !important",
minHeight: 36,
display: "flex",
alignItems: "center",
},
"&:hover, &:has(.MuiSelect-select[aria-expanded=\"true\"])": {
bgcolor: alpha("#000", 0.06),
color: "text.primary",
},
".MuiSelect-icon": {
color: "text.secondary",
right: 4,
},
}}
>
{approvalModeOptions.map((option) => {
const ApprovalIcon = option.icon;
const isAlways = option.value === "always";
return (
<MenuItem key={option.value} value={option.value}>
<ApprovalIcon
className={isAlways ? "icon always-icon" : "icon"}
sx={{
mr: 1.5,
mt: 0.15,
fontSize: 18,
color: isAlways ? "warning.main" : "text.secondary",
}}
/>
<Box>
<Typography
className="title"
sx={{
mb: 0.2,
color: "text.primary",
fontSize: "0.85rem",
fontWeight: 700,
}}
>
{option.label}
</Typography>
<Typography
sx={{
color: "text.secondary",
fontSize: "0.7rem",
fontWeight: 500,
lineHeight: 1.3,
whiteSpace: "nowrap",
}}
>
{option.description}
</Typography>
</Box>
</MenuItem>
);
})}
</Select>
</FormControl>
</Stack>
<Stack direction="row" spacing={1} alignItems="center">
<FormControl size="small" sx={{ minWidth: 80 }}>
<Select
value={selectedModel ?? ""}
onChange={(event) => onModelChange(event.target.value as AgentModel)}
disabled={isHydrating || isStreaming || modelOptions.length === 0}
aria-label="模型选择"
renderValue={() => (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
{renderModelIcon(selectedModelOption?.icon, {
sx: {
fontSize: selectedModelOption?.icon === "bolt" ? 18 : 16,
color: "inherit",
transition: "color 0.2s",
},
})}
<Typography sx={{ fontSize: "0.8rem", fontWeight: 600, color: "inherit", transition: "color 0.2s" }}>
{selectedModelOption?.label ?? "模型"}
</Typography>
</Box>
)}
MenuProps={{
anchorOrigin: { vertical: "top", horizontal: "center" },
transformOrigin: { vertical: "bottom", horizontal: "center" },
sx: { zIndex: (theme) => theme.zIndex.modal + 110 },
PaperProps: {
sx: {
mb: 1.5,
width: 230,
borderRadius: 4,
bgcolor: alpha("#fff", 0.85),
backdropFilter: "blur(24px)",
border: `1px solid ${alpha("#fff", 0.9)}`,
boxShadow: `0 -12px 40px ${alpha("#000", 0.08)}, 0 0 0 1px ${alpha("#00acc1", 0.05)} inset`,
"& .MuiList-root": {
p: 1,
},
"& .MuiMenuItem-root": {
px: 1.5,
py: 1.2,
mb: 0.5,
"&:last-child": { mb: 0 },
borderRadius: 3,
alignItems: "flex-start",
transition: "all 0.2s ease",
"&:hover": {
bgcolor: alpha("#000", 0.03),
},
"&.Mui-selected": {
bgcolor: alpha("#00acc1", 0.08),
"&:hover": {
bgcolor: alpha("#00acc1", 0.12),
},
"& .title": { color: "#00838f" },
"& .icon": { color: "#00acc1" },
}
}
}
}
}}
sx={{
height: 36,
borderRadius: "18px",
bgcolor: "transparent",
color: "text.secondary",
transition: "all 0.2s ease",
".MuiOutlinedInput-notchedOutline": {
border: "none",
},
".MuiSelect-select": {
py: 0,
pl: 1,
pr: "28px !important",
display: "flex",
alignItems: "center",
},
"&:hover, &:has(.MuiSelect-select[aria-expanded=\"true\"])": {
bgcolor: alpha("#000", 0.06),
color: "text.primary",
".MuiSelect-icon": {
color: "text.primary",
}
},
".MuiSelect-icon": {
color: "text.secondary",
right: 4,
transition: "color 0.2s ease",
}
}}
>
<Box sx={{ px: 2, py: 1.5, pb: 1, display: "flex", alignItems: "center", gap: 1, pointerEvents: "none" }}>
<AutoAwesomeRounded sx={{ width: 16, height: 16, color: "text.secondary", flexShrink: 0 }} />
<Typography sx={{ fontSize: "0.75rem", fontWeight: 700, color: "text.secondary", letterSpacing: 0.5 }}>
模型选择
</Typography>
</Box>
{modelOptions.map((model) => (
<MenuItem key={model.id} value={model.id}>
{renderModelIcon(model.icon, {
className: "icon",
sx: { mr: 1.5, mt: 0.2, fontSize: model.icon === "bolt" ? 20 : 18, color: "text.secondary", transition: "color 0.2s" },
})}
<Box>
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>{model.label}</Typography>
{model.description ? (
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>{model.description}</Typography>
) : null}
</Box>
</MenuItem>
))}
</Select>
</FormControl>
{isSttSupported ? (
isListening ? (
<motion.div
animate={{ scale: [1, 1.14, 1] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
>
<IconButton
onClick={onStopListening}
aria-label="停止语音输入"
size="small"
sx={{
color: "error.main",
bgcolor: alpha(theme.palette.error.main, 0.15),
width: 36,
height: 36,
}}
>
<MicRounded fontSize="small" />
</IconButton>
</motion.div>
) : (
<IconButton
onClick={onStartListening}
disabled={isStreaming || isHydrating || !isRuntimeReady}
aria-label="语音输入"
size="small"
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
>
<MicRounded fontSize="small" />
</IconButton>
)
) : null}
<AnimatePresence mode="wait">
{isStreaming ? (
<motion.div key="stop" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
<IconButton
onClick={onAbort}
aria-label="停止生成"
size="small"
sx={{
bgcolor: "error.main",
color: "#fff",
width: 40,
height: 40,
boxShadow: `0 4px 12px ${alpha(theme.palette.error.main, 0.4)}`,
"&:hover": { bgcolor: "error.dark" },
}}
>
<StopRounded />
</IconButton>
</motion.div>
) : (
<motion.div key="send" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
<IconButton
disabled={!canSend}
onClick={handleSend}
aria-label="发送"
size="small"
sx={{
bgcolor: canSend ? "#00acc1" : alpha("#fff", 0.5),
color: canSend ? "#fff" : "action.disabled",
width: 40,
height: 40,
boxShadow: canSend ? `0 6px 16px ${alpha("#00acc1", 0.4)}` : "none",
"&:hover": { bgcolor: canSend ? "#00838f" : alpha("#fff", 0.5) },
}}
>
<SendRounded sx={{ ml: 0.35 }} />
</IconButton>
</motion.div>
)}
</AnimatePresence>
</Stack>
</Stack>
</Paper>
</motion.div>
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.5, opacity: 0.6 }}>
<Image
src="/deepseek-logo.svg"
alt="DeepSeek"
width={14}
height={14}
style={{ width: 14, height: 14 }}
/>
<Typography variant="caption" sx={{ fontSize: "0.65rem", color: "text.secondary", fontWeight: 500, letterSpacing: 0.5 }}>
Powered by DeepSeek V4 · TJWater Agent Intelligence
</Typography>
</Box>
</Box>
);
});