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

565 lines
20 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 React from "react";
import {
Box,
Button,
Checkbox,
Chip,
CircularProgress,
Collapse,
FormControlLabel,
Stack,
TextField,
Typography,
alpha,
useTheme,
} from "@mui/material";
import type { Theme } from "@mui/material/styles";
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
import HelpOutlineRounded from "@mui/icons-material/HelpOutlineRounded";
import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded";
import type { Message } from "./GlobalChatbox.types";
const getQuestionStatusLabel = (
status: NonNullable<Message["questions"]>[number]["status"],
) => {
if (status === "answered") return "已回答";
if (status === "rejected") return "已跳过";
if (status === "error") return "提交失败";
if (status === "submitting") return "提交中";
return "等待回答";
};
const getQuestionStatusColor = (
status: NonNullable<Message["questions"]>[number]["status"],
theme: Theme,
) => {
if (status === "answered") return theme.palette.success.main;
if (status === "rejected") return theme.palette.text.secondary;
if (status === "error") return theme.palette.error.main;
return "#0288d1";
};
const QuestionRequestCard = ({
questionRequest,
onReply,
onReject,
}: {
questionRequest: NonNullable<Message["questions"]>[number];
onReply: (requestId: string, answers: string[][]) => void;
onReject: (requestId: string) => void;
}) => {
const theme = useTheme();
const isEditable =
questionRequest.status === "pending" || questionRequest.status === "error";
const isSubmitting = questionRequest.status === "submitting";
const statusColor = getQuestionStatusColor(questionRequest.status, theme);
const [selected, setSelected] = React.useState<Record<number, string[]>>({});
const [customSelected, setCustomSelected] = React.useState<Record<number, boolean>>({});
const [custom, setCustom] = React.useState<Record<number, string>>({});
const answers = React.useMemo(
() =>
questionRequest.questions.map((question, index) => {
const selectedAnswers = selected[index] ?? [];
const isCustomSelected =
customSelected[index] === true ||
(question.custom !== false && question.options.length === 0);
const customAnswer = custom[index]?.trim();
return isCustomSelected && customAnswer
? [...selectedAnswers, customAnswer]
: selectedAnswers;
}),
[custom, customSelected, questionRequest.questions, selected],
);
const canSubmit =
isEditable &&
questionRequest.questions.length > 0 &&
questionRequest.questions.every((_, index) => {
const answer = answers[index] ?? [];
return answer.some((item) => item.trim().length > 0);
});
const answerSummary = (questionRequest.answers ?? [])
.map((answer) => answer.join("、"))
.filter(Boolean)
.join("");
return (
<Box
sx={{
borderRadius: 3,
overflow: "hidden",
border: `1px solid ${alpha("#fff", 0.72)}`,
bgcolor: alpha("#fff", 0.52),
boxShadow: `0 8px 24px ${alpha("#000", 0.05)}`,
backdropFilter: "blur(20px)",
position: "relative",
"&::before": {
content: '""',
position: "absolute",
inset: "10px auto 10px 0",
width: 3,
borderRadius: "0 999px 999px 0",
bgcolor: statusColor,
},
}}
>
<Stack
direction="row"
spacing={1}
alignItems="center"
sx={{
px: 1.5,
py: 1.25,
pl: 1.75,
borderBottom: `1px solid ${alpha("#000", 0.05)}`,
}}
>
<Box
sx={{
width: 32,
height: 32,
borderRadius: "50%",
display: "grid",
placeItems: "center",
flex: "0 0 auto",
color: statusColor,
bgcolor: alpha(statusColor, 0.1),
border: `1px solid ${alpha(statusColor, 0.16)}`,
}}
>
<HelpOutlineRounded sx={{ fontSize: 21 }} />
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
</Typography>
</Box>
<Chip
size="small"
label={getQuestionStatusLabel(questionRequest.status)}
sx={{
height: 24,
fontSize: "0.7rem",
fontWeight: 800,
borderRadius: "12px",
bgcolor: alpha(statusColor, 0.12),
color: statusColor,
"& .MuiChip-label": { px: 1 },
}}
/>
</Stack>
<Stack spacing={1.3} sx={{ px: 1.5, py: 1.35, pl: 1.75 }}>
{questionRequest.questions.map((question, index) => {
const selectedAnswers = selected[index] ?? [];
const isCustomEnabled = question.custom !== false;
const isCustomSelected =
customSelected[index] === true ||
(isCustomEnabled && question.options.length === 0);
const setQuestionAnswers = (nextAnswers: string[]) => {
setSelected((current) => ({
...current,
[index]: nextAnswers,
}));
};
const setQuestionCustomSelected = (checked: boolean) => {
setCustomSelected((current) => ({
...current,
[index]: checked,
}));
};
return (
<Box
key={`${question.header}-${index}`}
sx={{
px: 1.25,
py: 1,
borderRadius: 2.5,
bgcolor: alpha("#000", 0.025),
border: `1px solid ${alpha("#000", 0.045)}`,
}}
>
<Typography variant="caption" color="text.secondary" fontWeight={800}>
{question.header || `问题 ${index + 1}`}
</Typography>
<Typography
variant="body2"
color="text.primary"
sx={{ mt: 0.35, lineHeight: 1.55, wordBreak: "break-word" }}
>
{question.question}
</Typography>
{question.options.length ? (
<Stack spacing={0.75} sx={{ mt: 1 }}>
{question.options.map((option) => {
const checked = selectedAnswers.includes(option.label);
if (question.multiple) {
return (
<FormControlLabel
key={option.label}
disabled={!isEditable || isSubmitting}
control={
<Checkbox
size="small"
checked={checked}
onChange={(event) => {
if (event.target.checked) {
setQuestionAnswers([...selectedAnswers, option.label]);
} else {
setQuestionAnswers(
selectedAnswers.filter((item) => item !== option.label),
);
}
}}
/>
}
label={
<Box>
<Typography variant="body2" fontWeight={750}>
{option.label}
</Typography>
{option.description ? (
<Typography variant="caption" color="text.secondary">
{option.description}
</Typography>
) : null}
</Box>
}
sx={{ alignItems: "flex-start", m: 0 }}
/>
);
}
return (
<Button
key={option.label}
size="small"
variant={checked ? "contained" : "outlined"}
disabled={!isEditable || isSubmitting}
onClick={() => {
setQuestionAnswers([option.label]);
setQuestionCustomSelected(false);
}}
startIcon={
checked ? (
<CheckCircleRounded fontSize="small" />
) : (
<RadioButtonUncheckedRounded fontSize="small" />
)
}
sx={{
justifyContent: "flex-start",
minHeight: 38,
borderRadius: 2,
textTransform: "none",
fontWeight: 800,
bgcolor: checked ? "#0288d1" : alpha("#fff", 0.45),
borderColor: checked ? "#0288d1" : alpha("#0288d1", 0.22),
"&:hover": {
bgcolor: checked ? "#0277bd" : alpha("#0288d1", 0.08),
},
}}
>
<Box sx={{ textAlign: "left", minWidth: 0 }}>
<Typography variant="body2" fontWeight={800}>
{option.label}
</Typography>
{option.description ? (
<Typography
variant="caption"
sx={{ display: "block", opacity: checked ? 0.86 : 0.72 }}
>
{option.description}
</Typography>
) : null}
</Box>
</Button>
);
})}
{isCustomEnabled ? (
question.multiple ? (
<FormControlLabel
disabled={!isEditable || isSubmitting}
control={
<Checkbox
size="small"
checked={isCustomSelected}
onChange={(event) =>
setQuestionCustomSelected(event.target.checked)
}
sx={{
p: 0.5,
color: alpha("#0288d1", 0.55),
"&.Mui-checked": { color: "#0288d1" },
}}
/>
}
label={
<Stack direction="row" spacing={0.75} alignItems="center">
<EditNoteRounded sx={{ fontSize: 18, color: "#0288d1" }} />
<Typography variant="body2" fontWeight={800}>
</Typography>
</Stack>
}
sx={{
alignItems: "center",
minHeight: 38,
m: 0,
px: 0.75,
py: 0.25,
borderRadius: 2,
border: `1px solid ${
isCustomSelected ? "#0288d1" : alpha("#0288d1", 0.18)
}`,
bgcolor: isCustomSelected
? alpha("#0288d1", 0.1)
: alpha("#fff", 0.45),
transition: "background-color 0.18s ease, border-color 0.18s ease",
"&:hover": {
bgcolor: isCustomSelected
? alpha("#0288d1", 0.13)
: alpha("#0288d1", 0.07),
},
"& .MuiFormControlLabel-label": {
color: isCustomSelected ? "#0277bd" : "text.primary",
},
}}
/>
) : (
<Button
size="small"
variant={isCustomSelected ? "contained" : "outlined"}
disabled={!isEditable || isSubmitting}
onClick={() => {
setQuestionAnswers([]);
setQuestionCustomSelected(true);
}}
startIcon={
isCustomSelected ? (
<CheckCircleRounded fontSize="small" />
) : (
<EditNoteRounded fontSize="small" />
)
}
sx={{
justifyContent: "flex-start",
minHeight: 38,
borderRadius: 2,
textTransform: "none",
fontWeight: 800,
bgcolor: isCustomSelected ? "#0288d1" : alpha("#fff", 0.45),
borderColor: isCustomSelected
? "#0288d1"
: alpha("#0288d1", 0.22),
"&:hover": {
bgcolor: isCustomSelected
? "#0277bd"
: alpha("#0288d1", 0.08),
},
}}
>
<Box sx={{ textAlign: "left", minWidth: 0 }}>
<Typography variant="body2" fontWeight={800}>
</Typography>
</Box>
</Button>
)
) : null}
</Stack>
) : null}
<Collapse in={isCustomEnabled && isCustomSelected} timeout="auto" unmountOnExit>
<Box
sx={{
mt: 0.85,
px: 1.15,
py: 0.85,
borderRadius: 2.5,
bgcolor: alpha("#fff", 0.62),
border: `1px solid ${alpha("#fff", 0.82)}`,
boxShadow: `0 8px 22px ${alpha("#000", 0.045)}, 0 0 0 1px ${alpha("#0288d1", 0.05)} inset`,
backdropFilter: "blur(18px)",
}}
>
<TextField
multiline
minRows={2}
maxRows={5}
fullWidth
variant="standard"
disabled={!isEditable || isSubmitting}
value={custom[index] ?? ""}
onChange={(event) =>
setCustom((current) => ({
...current,
[index]: event.target.value,
}))
}
placeholder="输入自定义回答"
InputProps={{
disableUnderline: true,
sx: {
alignItems: "flex-start",
fontSize: "0.88rem",
lineHeight: 1.55,
fontWeight: 500,
color: "text.primary",
"& textarea::placeholder": {
color: alpha(theme.palette.text.primary, 0.38),
opacity: 1,
},
},
}}
/>
</Box>
</Collapse>
</Box>
);
})}
{questionRequest.status === "answered" ? (
<Typography
variant="caption"
color="success.main"
sx={{
display: "block",
px: 1.25,
py: 0.75,
borderRadius: 2,
bgcolor: alpha(theme.palette.success.main, 0.07),
wordBreak: "break-word",
}}
>
{answerSummary ? `${answerSummary}` : ""}
</Typography>
) : null}
{questionRequest.status === "rejected" ? (
<Typography
variant="caption"
color="text.secondary"
sx={{
display: "block",
px: 1.25,
py: 0.75,
borderRadius: 2,
bgcolor: alpha("#000", 0.035),
}}
>
</Typography>
) : null}
{questionRequest.error ? (
<Typography
variant="caption"
color="error.main"
sx={{
display: "block",
px: 1.25,
py: 0.75,
borderRadius: 2,
bgcolor: alpha(theme.palette.error.main, 0.06),
wordBreak: "break-word",
}}
>
{questionRequest.error}
</Typography>
) : null}
</Stack>
{isEditable || isSubmitting ? (
<Stack
direction="row"
spacing={1}
flexWrap="wrap"
useFlexGap
sx={{ px: 1.5, pb: 1.35, pl: 1.75 }}
>
<Button
size="small"
variant="outlined"
disabled={isSubmitting}
onClick={() => onReject(questionRequest.requestId)}
sx={{
height: 34,
borderRadius: "17px",
px: 1.5,
fontWeight: 800,
fontSize: "0.78rem",
textTransform: "none",
color: "text.secondary",
borderColor: alpha(theme.palette.text.secondary, 0.22),
bgcolor: alpha("#fff", 0.45),
}}
>
</Button>
<Button
size="small"
variant="contained"
disableElevation
disabled={!canSubmit || isSubmitting}
onClick={() => onReply(questionRequest.requestId, answers)}
startIcon={
isSubmitting ? (
<CircularProgress size={14} color="inherit" />
) : (
<CheckCircleRounded fontSize="small" />
)
}
sx={{
minWidth: 104,
height: 34,
borderRadius: "17px",
bgcolor: "#0288d1",
fontWeight: 800,
fontSize: "0.78rem",
textTransform: "none",
boxShadow: `0 4px 12px ${alpha("#0288d1", 0.24)}`,
"&:hover": {
bgcolor: "#0277bd",
boxShadow: `0 6px 16px ${alpha("#0288d1", 0.28)}`,
},
}}
>
</Button>
</Stack>
) : null}
</Box>
);
};
export const QuestionRequestGroup = ({
questions,
onReply,
onReject,
}: {
questions: NonNullable<Message["questions"]>;
onReply: (requestId: string, answers: string[][]) => void;
onReject: (requestId: string) => void;
}) => (
<Stack spacing={1}>
{questions.map((question) => (
<QuestionRequestCard
key={question.requestId}
questionRequest={question}
onReply={onReply}
onReject={onReject}
/>
))}
</Stack>
);