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

274 lines
9.6 KiB
TypeScript

"use client";
import Image from "next/image";
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
Box,
Chip,
Collapse,
IconButton,
Paper,
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 AttachFileRounded from "@mui/icons-material/AttachFileRounded";
type AgentComposerProps = {
input: string;
inputRef: React.RefObject<HTMLInputElement | null>;
isHydrating?: boolean;
isStreaming: boolean;
isListening: boolean;
isSttSupported: boolean;
presets: string[];
onInputChange: (value: string) => void;
onSend: () => void;
onAbort: () => void;
onStartListening: () => void;
onStopListening: () => void;
onPresetSelect: (prompt: string) => void;
};
export const AgentComposer = ({
input,
inputRef,
isHydrating = false,
isStreaming,
isListening,
isSttSupported,
presets,
onInputChange,
onSend,
onAbort,
onStartListening,
onStopListening,
onPresetSelect,
}: AgentComposerProps) => {
const theme = useTheme();
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
const [isPresetOpen, setIsPresetOpen] = React.useState(false);
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">
<Image
src="/ai-agent.svg"
alt="TJWater Agent"
width={18}
height={18}
style={{
objectFit: "contain",
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
onClick={() => {
onPresetSelect(prompt);
setIsPresetOpen(false);
}}
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) => onInputChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
onSend();
}
}}
placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."}
fullWidth
multiline
maxRows={5}
variant="standard"
disabled={isHydrating}
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">
<IconButton size="small" aria-label="上传附件" sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}>
<AttachFileRounded fontSize="small" />
</IconButton>
{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}
aria-label="语音输入"
size="small"
sx={{ color: "text.secondary", width: 36, height: 36, bgcolor: alpha("#fff", 0.6) }}
>
<MicRounded fontSize="small" />
</IconButton>
)
) : null}
</Stack>
<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={onSend}
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>
</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>
);
};