402 lines
13 KiB
TypeScript
402 lines
13 KiB
TypeScript
"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 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,
|
|
options?: { startOffset?: number },
|
|
) => 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[];
|
|
isAssistantStreaming: boolean;
|
|
streamingMessageId: string | null;
|
|
speakingMessageId: string | null;
|
|
speechState: SpeechState;
|
|
onSpeak: (
|
|
messageId: string,
|
|
text: string,
|
|
options?: { startOffset?: number },
|
|
) => 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 TurnItem = React.memo(AgentTurn);
|
|
|
|
const TurnListInner = ({
|
|
messages,
|
|
isAssistantStreaming,
|
|
streamingMessageId,
|
|
speakingMessageId,
|
|
speechState,
|
|
onSpeak,
|
|
onPauseSpeech,
|
|
onResumeSpeech,
|
|
onStopSpeech,
|
|
isTtsSupported,
|
|
onCreateBranch,
|
|
onReplyPermission,
|
|
onReplyQuestion,
|
|
onRejectQuestion,
|
|
}: TurnListProps) => {
|
|
return (
|
|
<>
|
|
{messages.map((message) => (
|
|
<TurnItem
|
|
key={message.id}
|
|
message={message}
|
|
isStreaming={isAssistantStreaming && message.id === streamingMessageId}
|
|
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.isAssistantStreaming === nextProps.isAssistantStreaming &&
|
|
prevProps.streamingMessageId === nextProps.streamingMessageId &&
|
|
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 streamingMessageId =
|
|
isStreaming && messages.at(-1)?.role === "assistant"
|
|
? messages.at(-1)?.id ?? null
|
|
: null;
|
|
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={messages}
|
|
isAssistantStreaming={isStreaming}
|
|
streamingMessageId={streamingMessageId}
|
|
speakingMessageId={speakingMessageId}
|
|
speechState={speechState}
|
|
onSpeak={onSpeak}
|
|
onPauseSpeech={onPauseSpeech}
|
|
onResumeSpeech={onResumeSpeech}
|
|
onStopSpeech={onStopSpeech}
|
|
isTtsSupported={isTtsSupported}
|
|
onCreateBranch={onCreateBranch}
|
|
onReplyPermission={onReplyPermission}
|
|
onReplyQuestion={onReplyQuestion}
|
|
onRejectQuestion={onRejectQuestion}
|
|
/>
|
|
</Box>
|
|
) : null}
|
|
</>
|
|
)}
|
|
|
|
<div
|
|
ref={bottomRef}
|
|
style={{
|
|
flexShrink: 0,
|
|
height: isStreaming ? STREAMING_BOTTOM_RESERVE_PX : 1,
|
|
}}
|
|
/>
|
|
</Box>
|
|
);
|
|
};
|