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

441 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Image from "next/image";
import React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Box, Paper, Skeleton, 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;
isLoadingSession?: boolean;
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
bottomRef: React.RefObject<HTMLDivElement | null>;
onScrollStateChange?: (isNearBottom: boolean) => void;
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 STREAMING_BOTTOM_RESERVE_PX = 180;
const STREAMING_NEAR_BOTTOM_THRESHOLD_PX = STREAMING_BOTTOM_RESERVE_PX + 120;
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>
);
};
const SessionLoadingSkeleton = () => (
<Stack
spacing={2.25}
aria-label="正在加载历史记录"
sx={{ width: "100%", maxWidth: 760, alignSelf: "stretch" }}
>
{Array.from({ length: 2 }, (_, turnIndex) => (
<Stack key={turnIndex} spacing={1.25}>
<Stack direction="row" justifyContent="flex-end">
<Paper
elevation={0}
sx={{
width: turnIndex === 0 ? "72%" : "64%",
maxWidth: "86%",
p: 1.75,
borderRadius: 5,
borderBottomRightRadius: 2,
bgcolor: alpha("#00acc1", 0.16),
border: `1px solid ${alpha("#00acc1", 0.12)}`,
boxShadow: `0 8px 24px -12px ${alpha("#00acc1", 0.35)}`,
}}
>
<Stack spacing={0.85}>
<Skeleton variant="text" width="76%" height={18} />
<Skeleton variant="text" width="48%" height={15} />
</Stack>
</Paper>
</Stack>
<Stack direction="row" spacing={1.5} alignItems="flex-start">
<Skeleton
variant="circular"
width={34}
height={34}
sx={{ bgcolor: alpha("#00acc1", 0.12), flexShrink: 0, mt: 0.25 }}
/>
<Paper
elevation={0}
sx={{
flex: 1,
minWidth: 0,
p: 2,
borderRadius: 5,
bgcolor: alpha("#ffffff", 0.52),
border: `1px solid ${alpha("#fff", 0.72)}`,
boxShadow: `0 10px 30px -10px ${alpha("#000", 0.06)}`,
}}
>
<Stack spacing={1}>
<Skeleton variant="text" width="38%" height={16} />
<Skeleton variant="text" width="94%" height={16} />
<Skeleton variant="text" width={turnIndex === 0 ? "88%" : "82%"} height={16} />
<Skeleton variant="text" width={turnIndex === 0 ? "78%" : "70%"} height={16} />
<Skeleton variant="rounded" width="100%" height={turnIndex === 0 ? 104 : 76} sx={{ borderRadius: 2 }} />
</Stack>
</Paper>
</Stack>
</Stack>
))}
</Stack>
);
export const AgentWorkspace = ({
messages,
isStreaming,
isLoadingSession = false,
scrollContainerRef,
bottomRef,
onScrollStateChange,
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;
const handleScroll = React.useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
if (!onScrollStateChange) return;
const target = event.currentTarget;
const distanceToBottom =
target.scrollHeight - target.scrollTop - target.clientHeight;
onScrollStateChange(
distanceToBottom <
(isStreaming ? STREAMING_NEAR_BOTTOM_THRESHOLD_PX : 96),
);
},
[isStreaming, onScrollStateChange],
);
return (
<Box
ref={scrollContainerRef}
onScroll={handleScroll}
sx={{
flex: 1,
overflowY: "auto",
px: 2.5,
py: 2,
display: "flex",
flexDirection: "column",
scrollbarGutter: "stable",
zIndex: 5,
}}
>
{isLoadingSession ? (
<SessionLoadingSkeleton />
) : (
<>
<AnimatePresence initial={false}>
{messages.length === 0 ? <EmptyState /> : null}
</AnimatePresence>
{messages.length > 0 ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<TurnList
messages={historyMessages}
isStreaming={false}
speakingMessageId={speakingMessageId}
speechState={speechState}
onSpeak={onSpeak}
onPauseSpeech={onPauseSpeech}
onResumeSpeech={onResumeSpeech}
onStopSpeech={onStopSpeech}
isTtsSupported={isTtsSupported}
onCreateBranch={onCreateBranch}
onReplyPermission={onReplyPermission}
onReplyQuestion={onReplyQuestion}
onRejectQuestion={onRejectQuestion}
/>
{streamingMessage ? (
<Box sx={{ width: "100%" }}>
<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}
/>
</Box>
) : null}
</Box>
) : null}
</>
)}
{!isLoadingSession && 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={{
flexShrink: 0,
height: isStreaming ? STREAMING_BOTTOM_RESERVE_PX : 1,
}}
/>
</Box>
);
};