Files
TJWaterFrontend_Refine/src/components/chat/AgentWorkspace.tsx
T
jiang cf6386d209
Build Push and Deploy / docker-image (push) Failing after 1m17s
Build Push and Deploy / deploy-fallback-log (push) Successful in 0s
feat(chat): add Edge TTS playback
2026-07-08 18:39:49 +08:00

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>
);
};