425 lines
17 KiB
TypeScript
425 lines
17 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 AttachFileRounded from "@mui/icons-material/AttachFileRounded";
|
|
import BoltRounded from "@mui/icons-material/BoltRounded";
|
|
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
|
|
import type { 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;
|
|
isStreaming: boolean;
|
|
isListening: boolean;
|
|
isSttSupported: boolean;
|
|
presets: string[];
|
|
onSend: (prompt: string) => void;
|
|
onAbort: () => void;
|
|
onStartListening: () => void;
|
|
onStopListening: () => void;
|
|
selectedModel: AgentModel;
|
|
onModelChange: (model: AgentModel) => void;
|
|
};
|
|
|
|
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
|
|
isHydrating = false,
|
|
isStreaming,
|
|
isListening,
|
|
isSttSupported,
|
|
presets,
|
|
onSend,
|
|
onAbort,
|
|
onStartListening,
|
|
onStopListening,
|
|
selectedModel,
|
|
onModelChange,
|
|
}, ref) {
|
|
const theme = useTheme();
|
|
const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
|
|
const [input, setInput] = React.useState("");
|
|
const [isPresetOpen, setIsPresetOpen] = React.useState(false);
|
|
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
|
|
|
|
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) return;
|
|
setInput("");
|
|
onSend(prompt);
|
|
}, [input, isHydrating, 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">
|
|
<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={() => {
|
|
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={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>
|
|
|
|
<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}
|
|
aria-label="模型选择"
|
|
renderValue={(val) => (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
{val === "deepseek/deepseek-v4-flash" ? (
|
|
<BoltRounded sx={{ fontSize: 18, color: "inherit", transition: "color 0.2s" }} />
|
|
) : (
|
|
<AutoAwesomeRounded sx={{ fontSize: 16, color: "inherit", transition: "color 0.2s" }} />
|
|
)}
|
|
<Typography sx={{ fontSize: "0.8rem", fontWeight: 600, color: "inherit", transition: "color 0.2s" }}>
|
|
{val === "deepseek/deepseek-v4-flash" ? "快速" : "专家"}
|
|
</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" }}>
|
|
<Box
|
|
component="img"
|
|
src="/deepseek-logo.svg"
|
|
alt="DeepSeek"
|
|
sx={{ width: 16, height: 16, display: "block", flexShrink: 0 }}
|
|
/>
|
|
<Typography sx={{ fontSize: "0.75rem", fontWeight: 700, color: "text.secondary", letterSpacing: 0.5 }}>
|
|
DEEPSEEK V4
|
|
</Typography>
|
|
</Box>
|
|
<MenuItem value="deepseek/deepseek-v4-flash">
|
|
<BoltRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 20, 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" }}>快速</Typography>
|
|
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>快速回答和任务执行</Typography>
|
|
</Box>
|
|
</MenuItem>
|
|
<MenuItem value="deepseek/deepseek-v4-pro">
|
|
<AutoAwesomeRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 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" }}>专家</Typography>
|
|
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>探索、解决复杂任务</Typography>
|
|
</Box>
|
|
</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
|
|
<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>
|
|
);
|
|
});
|