340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import Image from "next/image";
|
||
import React from "react";
|
||
import { AnimatePresence, motion } from "framer-motion";
|
||
import { Box, Paper, Stack, Typography, alpha, useTheme, Grid } from "@mui/material";
|
||
import WaterDropRounded from "@mui/icons-material/WaterDropRounded";
|
||
import SensorsRounded from "@mui/icons-material/SensorsRounded";
|
||
import TroubleshootRounded from "@mui/icons-material/TroubleshootRounded";
|
||
import MapRounded from "@mui/icons-material/MapRounded";
|
||
|
||
import { AgentTurn } from "./AgentTurn";
|
||
import { TypingIndicator } from "./GlobalChatbox.parts";
|
||
import type { PermissionReply } from "@/lib/chatStream";
|
||
import type {
|
||
Message,
|
||
SpeechState,
|
||
} from "./GlobalChatbox.types";
|
||
|
||
type AgentWorkspaceProps = {
|
||
messages: Message[];
|
||
isStreaming: boolean;
|
||
bottomRef: React.RefObject<HTMLDivElement | null>;
|
||
speakingMessageId: string | null;
|
||
speechState: SpeechState;
|
||
onSpeak: (messageId: string, text: string) => void;
|
||
onPauseSpeech: () => void;
|
||
onResumeSpeech: () => void;
|
||
onStopSpeech: () => void;
|
||
isTtsSupported: boolean;
|
||
onCreateBranch: (messageId: string) => void;
|
||
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
||
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
||
onRejectQuestion: (requestId: string) => void;
|
||
};
|
||
|
||
type TurnListProps = {
|
||
messages: Message[];
|
||
isStreaming: boolean;
|
||
speakingMessageId: string | null;
|
||
speechState: SpeechState;
|
||
onSpeak: (messageId: string, text: string) => void;
|
||
onPauseSpeech: () => void;
|
||
onResumeSpeech: () => void;
|
||
onStopSpeech: () => void;
|
||
isTtsSupported: boolean;
|
||
onCreateBranch: (messageId: string) => void;
|
||
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
||
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
||
onRejectQuestion: (requestId: string) => void;
|
||
};
|
||
|
||
const sameMessages = (left: Message[], right: Message[]) =>
|
||
left.length === right.length &&
|
||
left.every((message, index) => message === right[index]);
|
||
|
||
const TurnListInner = ({
|
||
messages,
|
||
isStreaming,
|
||
speakingMessageId,
|
||
speechState,
|
||
onSpeak,
|
||
onPauseSpeech,
|
||
onResumeSpeech,
|
||
onStopSpeech,
|
||
isTtsSupported,
|
||
onCreateBranch,
|
||
onReplyPermission,
|
||
onReplyQuestion,
|
||
onRejectQuestion,
|
||
}: TurnListProps) => {
|
||
return (
|
||
<>
|
||
{messages.map((message) => (
|
||
<AgentTurn
|
||
key={message.id}
|
||
message={message}
|
||
isStreaming={isStreaming}
|
||
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
|
||
onSpeak={onSpeak}
|
||
onPause={onPauseSpeech}
|
||
onResume={onResumeSpeech}
|
||
onStopSpeech={onStopSpeech}
|
||
isTtsSupported={isTtsSupported}
|
||
onCreateBranch={onCreateBranch}
|
||
onReplyPermission={onReplyPermission}
|
||
onReplyQuestion={onReplyQuestion}
|
||
onRejectQuestion={onRejectQuestion}
|
||
/>
|
||
))}
|
||
</>
|
||
);
|
||
};
|
||
|
||
const TurnList = React.memo(
|
||
TurnListInner,
|
||
(prevProps, nextProps) =>
|
||
sameMessages(prevProps.messages, nextProps.messages) &&
|
||
prevProps.isStreaming === nextProps.isStreaming &&
|
||
prevProps.speakingMessageId === nextProps.speakingMessageId &&
|
||
prevProps.speechState === nextProps.speechState &&
|
||
prevProps.onSpeak === nextProps.onSpeak &&
|
||
prevProps.onPauseSpeech === nextProps.onPauseSpeech &&
|
||
prevProps.onResumeSpeech === nextProps.onResumeSpeech &&
|
||
prevProps.onStopSpeech === nextProps.onStopSpeech &&
|
||
prevProps.isTtsSupported === nextProps.isTtsSupported &&
|
||
prevProps.onCreateBranch === nextProps.onCreateBranch &&
|
||
prevProps.onReplyPermission === nextProps.onReplyPermission &&
|
||
prevProps.onReplyQuestion === nextProps.onReplyQuestion &&
|
||
prevProps.onRejectQuestion === nextProps.onRejectQuestion,
|
||
);
|
||
|
||
TurnList.displayName = "TurnList";
|
||
|
||
const EmptyState = () => {
|
||
const theme = useTheme();
|
||
const capabilities = [
|
||
{ icon: <WaterDropRounded sx={{ fontSize: 20, color: "#00acc1" }} />, label: "水力瓶颈识别" },
|
||
{ icon: <SensorsRounded sx={{ fontSize: 20, color: "#0288d1" }} />, label: "异常状态预警" },
|
||
{ icon: <TroubleshootRounded sx={{ fontSize: 20, color: "#43a047" }} />, label: "调度与改造建议" },
|
||
{ icon: <MapRounded sx={{ fontSize: 20, color: "#8e24aa" }} />, label: "GIS 地图联动" },
|
||
];
|
||
|
||
return (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ type: "spring", stiffness: 200, damping: 20 }}
|
||
style={{ margin: "auto", width: "100%", maxWidth: 440, padding: 16 }}
|
||
>
|
||
<Paper
|
||
elevation={0}
|
||
sx={{
|
||
p: 4,
|
||
borderRadius: 4,
|
||
bgcolor: alpha("#ffffff", 0.4),
|
||
border: `1px solid ${alpha("#fff", 0.8)}`,
|
||
boxShadow: `0 16px 40px ${alpha("#000", 0.05)}`,
|
||
textAlign: "center",
|
||
backdropFilter: "blur(24px)",
|
||
position: "relative",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
<Box sx={{
|
||
position: "absolute",
|
||
top: -100,
|
||
right: -100,
|
||
width: 200,
|
||
height: 200,
|
||
background: "radial-gradient(circle, rgba(0, 172, 193, 0.15) 0%, rgba(255,255,255,0) 70%)",
|
||
}} />
|
||
<motion.div
|
||
animate={{
|
||
y: [-6, 4, -6],
|
||
scale: [1, 1.04, 1],
|
||
rotate: [-3, 3, -3],
|
||
}}
|
||
transition={{ duration: 4.8, repeat: Infinity, ease: "easeInOut" }}
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
width: 88,
|
||
height: 88,
|
||
marginBottom: 12,
|
||
borderRadius: "50%",
|
||
background: "radial-gradient(circle, rgba(255,255,255,0.92) 0%, rgba(255,255,255,0.45) 58%, rgba(255,255,255,0) 100%)",
|
||
boxShadow: "0 10px 28px rgba(0, 131, 143, 0.12)",
|
||
}}
|
||
>
|
||
<Image
|
||
src="/ai-agent.svg"
|
||
alt="TJWater Agent"
|
||
width={54}
|
||
height={54}
|
||
style={{
|
||
objectFit: "contain",
|
||
filter: "drop-shadow(0 4px 12px rgba(0, 131, 143, 0.2))",
|
||
}}
|
||
/>
|
||
</motion.div>
|
||
<Typography variant="h6" color="text.primary" fontWeight={800} gutterBottom>
|
||
我已就绪,请描述任务
|
||
</Typography>
|
||
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1.6, mb: 3 }}>
|
||
你可以使用自然语言下达指令,我会自主规划决策执行、并在地图上呈现分析结果。
|
||
</Typography>
|
||
|
||
<Grid container spacing={1.5}>
|
||
{capabilities.map((item) => (
|
||
<Grid item xs={6} key={item.label}>
|
||
<motion.div whileHover={{ y: -2, scale: 1.02 }} transition={{ duration: 0.2 }}>
|
||
<Stack
|
||
direction="row"
|
||
spacing={1}
|
||
alignItems="center"
|
||
justifyContent="center"
|
||
sx={{
|
||
px: 1.5,
|
||
py: 1.5,
|
||
borderRadius: 3,
|
||
bgcolor: alpha("#fff", 0.5),
|
||
border: `1px solid ${alpha("#fff", 0.6)}`,
|
||
boxShadow: `0 4px 12px ${alpha("#000", 0.03)}`,
|
||
color: "text.primary",
|
||
transition: "all 0.2s",
|
||
"&:hover": {
|
||
bgcolor: alpha("#fff", 0.8),
|
||
borderColor: alpha("#00acc1", 0.4),
|
||
boxShadow: `0 6px 16px ${alpha("#00acc1", 0.15)}`,
|
||
}
|
||
}}
|
||
>
|
||
{item.icon}
|
||
<Typography variant="caption" fontWeight={700}>
|
||
{item.label}
|
||
</Typography>
|
||
</Stack>
|
||
</motion.div>
|
||
</Grid>
|
||
))}
|
||
</Grid>
|
||
</Paper>
|
||
</motion.div>
|
||
);
|
||
};
|
||
|
||
export const AgentWorkspace = ({
|
||
messages,
|
||
isStreaming,
|
||
bottomRef,
|
||
speakingMessageId,
|
||
speechState,
|
||
onSpeak,
|
||
onPauseSpeech,
|
||
onResumeSpeech,
|
||
onStopSpeech,
|
||
isTtsSupported,
|
||
onCreateBranch,
|
||
onReplyPermission,
|
||
onReplyQuestion,
|
||
onRejectQuestion,
|
||
}: AgentWorkspaceProps) => {
|
||
const theme = useTheme();
|
||
const latestAssistant = [...messages]
|
||
.reverse()
|
||
.find((message) => message.role === "assistant");
|
||
const showTypingIndicator =
|
||
isStreaming &&
|
||
(!latestAssistant ||
|
||
(latestAssistant.content.trim().length === 0 &&
|
||
!(latestAssistant.artifacts?.length)));
|
||
const streamingMessage =
|
||
isStreaming && messages.at(-1)?.role === "assistant"
|
||
? messages.at(-1)
|
||
: undefined;
|
||
const historyMessages =
|
||
streamingMessage !== undefined ? messages.slice(0, -1) : messages;
|
||
|
||
return (
|
||
<Box
|
||
sx={{
|
||
flex: 1,
|
||
overflowY: "auto",
|
||
px: 2.5,
|
||
py: 2,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
zIndex: 5,
|
||
}}
|
||
>
|
||
<AnimatePresence initial={false}>
|
||
{messages.length === 0 ? <EmptyState /> : null}
|
||
</AnimatePresence>
|
||
|
||
{messages.length > 0 ? (
|
||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||
<TurnList
|
||
messages={historyMessages}
|
||
isStreaming={isStreaming}
|
||
speakingMessageId={speakingMessageId}
|
||
speechState={speechState}
|
||
onSpeak={onSpeak}
|
||
onPauseSpeech={onPauseSpeech}
|
||
onResumeSpeech={onResumeSpeech}
|
||
onStopSpeech={onStopSpeech}
|
||
isTtsSupported={isTtsSupported}
|
||
onCreateBranch={onCreateBranch}
|
||
onReplyPermission={onReplyPermission}
|
||
onReplyQuestion={onReplyQuestion}
|
||
onRejectQuestion={onRejectQuestion}
|
||
/>
|
||
|
||
{streamingMessage ? (
|
||
<TurnList
|
||
messages={[streamingMessage]}
|
||
isStreaming={isStreaming}
|
||
speakingMessageId={speakingMessageId}
|
||
speechState={speechState}
|
||
onSpeak={onSpeak}
|
||
onPauseSpeech={onPauseSpeech}
|
||
onResumeSpeech={onResumeSpeech}
|
||
onStopSpeech={onStopSpeech}
|
||
isTtsSupported={isTtsSupported}
|
||
onCreateBranch={onCreateBranch}
|
||
onReplyPermission={onReplyPermission}
|
||
onReplyQuestion={onReplyQuestion}
|
||
onRejectQuestion={onRejectQuestion}
|
||
/>
|
||
) : null}
|
||
</Box>
|
||
) : null}
|
||
|
||
{showTypingIndicator ? (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 10, scale: 0.94 }}
|
||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||
transition={{ type: "spring", stiffness: 300 }}
|
||
style={{ alignSelf: "flex-start", display: "flex", gap: 12, marginTop: 4, marginLeft: 44 }}
|
||
>
|
||
<Paper
|
||
elevation={0}
|
||
sx={{
|
||
p: 1.3,
|
||
borderRadius: 4,
|
||
bgcolor: alpha("#fff", 0.82),
|
||
boxShadow: `0 4px 12px ${alpha(theme.palette.common.black, 0.05)}`,
|
||
}}
|
||
>
|
||
<TypingIndicator />
|
||
</Paper>
|
||
</motion.div>
|
||
) : null}
|
||
|
||
<div ref={bottomRef} style={{ height: 1 }} />
|
||
</Box>
|
||
);
|
||
};
|