fix(chat): wire question and todo cards
This commit is contained in:
@@ -9,12 +9,15 @@ import {
|
|||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
Chip,
|
Chip,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Collapse,
|
Collapse,
|
||||||
|
FormControlLabel,
|
||||||
IconButton,
|
IconButton,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
|
TextField,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
alpha,
|
alpha,
|
||||||
@@ -50,6 +53,9 @@ import BlockRounded from "@mui/icons-material/BlockRounded";
|
|||||||
import PushPinRounded from "@mui/icons-material/PushPinRounded";
|
import PushPinRounded from "@mui/icons-material/PushPinRounded";
|
||||||
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||||
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||||
|
import AssignmentTurnedInRounded from "@mui/icons-material/AssignmentTurnedInRounded";
|
||||||
|
import HelpOutlineRounded from "@mui/icons-material/HelpOutlineRounded";
|
||||||
|
import RadioButtonUncheckedRounded from "@mui/icons-material/RadioButtonUncheckedRounded";
|
||||||
import type { PermissionReply } from "@/lib/chatStream";
|
import type { PermissionReply } from "@/lib/chatStream";
|
||||||
|
|
||||||
type AgentTurnProps = {
|
type AgentTurnProps = {
|
||||||
@@ -63,6 +69,8 @@ type AgentTurnProps = {
|
|||||||
onRegenerate: (messageId: string) => void;
|
onRegenerate: (messageId: string) => void;
|
||||||
onCreateBranch: (messageId: string) => void;
|
onCreateBranch: (messageId: string) => void;
|
||||||
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
||||||
|
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
||||||
|
onRejectQuestion: (requestId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, "");
|
const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, "");
|
||||||
@@ -670,6 +678,597 @@ const PermissionRequestGroup = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 [custom, setCustom] = React.useState<Record<number, string>>({});
|
||||||
|
|
||||||
|
const answers = React.useMemo(
|
||||||
|
() =>
|
||||||
|
questionRequest.questions.map((question, index) => {
|
||||||
|
const selectedAnswers = selected[index] ?? [];
|
||||||
|
const customAnswer = custom[index]?.trim();
|
||||||
|
return customAnswer ? [...selectedAnswers, customAnswer] : selectedAnswers;
|
||||||
|
}),
|
||||||
|
[custom, questionRequest.questions, selected],
|
||||||
|
);
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
isEditable &&
|
||||||
|
questionRequest.questions.length > 0 &&
|
||||||
|
questionRequest.questions.every((question, index) => {
|
||||||
|
const answer = answers[index] ?? [];
|
||||||
|
const hasInput = answer.some((item) => item.trim().length > 0);
|
||||||
|
const canAnswer = question.options.length > 0 || question.custom === true;
|
||||||
|
return canAnswer && hasInput;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 setQuestionAnswers = (nextAnswers: string[]) => {
|
||||||
|
setSelected((current) => ({
|
||||||
|
...current,
|
||||||
|
[index]: nextAnswers,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
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])}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{question.custom ? (
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
maxRows={5}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
disabled={!isEditable || isSubmitting}
|
||||||
|
value={custom[index] ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
setCustom((current) => ({
|
||||||
|
...current,
|
||||||
|
[index]: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="补充说明"
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TodoPlanCard = ({
|
||||||
|
todoUpdate,
|
||||||
|
}: {
|
||||||
|
todoUpdate: NonNullable<Message["todos"]>[number];
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const total = todoUpdate.todos.length;
|
||||||
|
const completed = todoUpdate.todos.filter((todo) => todo.status === "completed").length;
|
||||||
|
const running = todoUpdate.todos.find((todo) => todo.status === "in_progress");
|
||||||
|
const cancelled = todoUpdate.todos.filter((todo) => todo.status === "cancelled").length;
|
||||||
|
const isAborted = cancelled > 0 && !running;
|
||||||
|
const [expanded, setExpanded] = React.useState(
|
||||||
|
!isAborted && todoUpdate.todos.length <= 3,
|
||||||
|
);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isAborted) {
|
||||||
|
setExpanded(false);
|
||||||
|
}
|
||||||
|
}, [isAborted]);
|
||||||
|
const visibleTodos =
|
||||||
|
isAborted && !expanded
|
||||||
|
? []
|
||||||
|
: expanded || total <= 3
|
||||||
|
? todoUpdate.todos
|
||||||
|
: [
|
||||||
|
...todoUpdate.todos.slice(0, 3),
|
||||||
|
...(running && !todoUpdate.todos.slice(0, 3).some((todo) => todo.id === running.id)
|
||||||
|
? [running]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const getTodoVisual = (status: NonNullable<Message["todos"]>[number]["todos"][number]["status"]) => {
|
||||||
|
if (status === "completed") {
|
||||||
|
return { icon: <CheckCircleRounded sx={{ fontSize: 18 }} />, color: theme.palette.success.main, label: "已完成" };
|
||||||
|
}
|
||||||
|
if (status === "in_progress") {
|
||||||
|
return { icon: <CircularProgress size={16} />, color: "#0288d1", label: "进行中" };
|
||||||
|
}
|
||||||
|
if (status === "cancelled") {
|
||||||
|
return { icon: <BlockRounded sx={{ fontSize: 18 }} />, color: theme.palette.text.disabled, label: "已中止" };
|
||||||
|
}
|
||||||
|
return { icon: <RadioButtonUncheckedRounded sx={{ fontSize: 18 }} />, color: theme.palette.text.secondary, label: "待处理" };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: "hidden",
|
||||||
|
border: `1px solid ${alpha("#fff", 0.72)}`,
|
||||||
|
bgcolor: alpha("#fff", 0.48),
|
||||||
|
boxShadow: `0 8px 24px ${alpha("#000", 0.045)}`,
|
||||||
|
backdropFilter: "blur(20px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
spacing={1}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setExpanded((value) => !value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
setExpanded((value) => !value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
px: 1.5,
|
||||||
|
py: 1.15,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background-color 0.2s ease",
|
||||||
|
"&:hover": { bgcolor: alpha("#000", 0.025) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
color: "#00838f",
|
||||||
|
bgcolor: alpha("#00838f", 0.1),
|
||||||
|
border: `1px solid ${alpha("#00838f", 0.15)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AssignmentTurnedInRounded sx={{ fontSize: 18 }} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
|
||||||
|
任务规划
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{isAborted
|
||||||
|
? `${completed}/${total} 已完成,${cancelled} 项已中止`
|
||||||
|
: `${completed}/${total} 已完成${running ? ",1 项进行中" : ""}`}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={expanded ? "收起任务规划" : "展开任务规划"}
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
color: "text.secondary",
|
||||||
|
bgcolor: alpha("#000", 0.035),
|
||||||
|
"&:hover": { bgcolor: alpha("#000", 0.07) },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<KeyboardArrowUpRounded sx={{ fontSize: 18 }} />
|
||||||
|
) : (
|
||||||
|
<KeyboardArrowDownRounded sx={{ fontSize: 18 }} />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{visibleTodos.length ? (
|
||||||
|
<Stack spacing={0} sx={{ px: 1.5, pb: 1.2 }}>
|
||||||
|
{visibleTodos.map((todo, index) => {
|
||||||
|
const visual = getTodoVisual(todo.status);
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
key={`${todo.id}-${index}`}
|
||||||
|
direction="row"
|
||||||
|
alignItems="center"
|
||||||
|
spacing={1}
|
||||||
|
sx={{
|
||||||
|
py: 0.75,
|
||||||
|
borderTop: index === 0 ? `1px solid ${alpha("#000", 0.05)}` : "none",
|
||||||
|
color: todo.status === "cancelled" ? "text.disabled" : "text.primary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
color: visual.color,
|
||||||
|
bgcolor: alpha(visual.color, 0.08),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{visual.icon}
|
||||||
|
</Box>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
minWidth: 0,
|
||||||
|
flex: 1,
|
||||||
|
wordBreak: "break-word",
|
||||||
|
textDecoration: todo.status === "cancelled" ? "line-through" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{todo.content}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={visual.label}
|
||||||
|
sx={{
|
||||||
|
height: 22,
|
||||||
|
borderRadius: "11px",
|
||||||
|
fontSize: "0.68rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
color: visual.color,
|
||||||
|
bgcolor: alpha(visual.color, 0.08),
|
||||||
|
"& .MuiChip-label": { px: 0.85 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const AgentTurn = React.memo(
|
export const AgentTurn = React.memo(
|
||||||
({
|
({
|
||||||
message,
|
message,
|
||||||
@@ -682,6 +1281,8 @@ export const AgentTurn = React.memo(
|
|||||||
onRegenerate,
|
onRegenerate,
|
||||||
onCreateBranch,
|
onCreateBranch,
|
||||||
onReplyPermission,
|
onReplyPermission,
|
||||||
|
onReplyQuestion,
|
||||||
|
onRejectQuestion,
|
||||||
}: AgentTurnProps) => {
|
}: AgentTurnProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isUser = message.role === "user";
|
const isUser = message.role === "user";
|
||||||
@@ -824,6 +1425,18 @@ export const AgentTurn = React.memo(
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{message.questions?.length ? (
|
||||||
|
<QuestionRequestGroup
|
||||||
|
questions={message.questions}
|
||||||
|
onReply={onReplyQuestion}
|
||||||
|
onReject={onRejectQuestion}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{message.todos?.length ? (
|
||||||
|
<TodoPlanCard todoUpdate={message.todos[message.todos.length - 1]} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
p: 1.5,
|
p: 1.5,
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ describe("AgentWorkspace", () => {
|
|||||||
onRegenerate: jest.fn(),
|
onRegenerate: jest.fn(),
|
||||||
onCreateBranch: jest.fn(),
|
onCreateBranch: jest.fn(),
|
||||||
onReplyPermission: jest.fn(),
|
onReplyPermission: jest.fn(),
|
||||||
|
onReplyQuestion: jest.fn(),
|
||||||
|
onRejectQuestion: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ type AgentWorkspaceProps = {
|
|||||||
onRegenerate: (messageId: string) => void;
|
onRegenerate: (messageId: string) => void;
|
||||||
onCreateBranch: (messageId: string) => void;
|
onCreateBranch: (messageId: string) => void;
|
||||||
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
||||||
|
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
||||||
|
onRejectQuestion: (requestId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TurnListProps = {
|
type TurnListProps = {
|
||||||
@@ -45,6 +47,8 @@ type TurnListProps = {
|
|||||||
onRegenerate: (messageId: string) => void;
|
onRegenerate: (messageId: string) => void;
|
||||||
onCreateBranch: (messageId: string) => void;
|
onCreateBranch: (messageId: string) => void;
|
||||||
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
||||||
|
onReplyQuestion: (requestId: string, answers: string[][]) => void;
|
||||||
|
onRejectQuestion: (requestId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sameMessages = (left: Message[], right: Message[]) =>
|
const sameMessages = (left: Message[], right: Message[]) =>
|
||||||
@@ -63,6 +67,8 @@ const TurnListInner = ({
|
|||||||
onRegenerate,
|
onRegenerate,
|
||||||
onCreateBranch,
|
onCreateBranch,
|
||||||
onReplyPermission,
|
onReplyPermission,
|
||||||
|
onReplyQuestion,
|
||||||
|
onRejectQuestion,
|
||||||
}: TurnListProps) => {
|
}: TurnListProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -79,6 +85,8 @@ const TurnListInner = ({
|
|||||||
onRegenerate={onRegenerate}
|
onRegenerate={onRegenerate}
|
||||||
onCreateBranch={onCreateBranch}
|
onCreateBranch={onCreateBranch}
|
||||||
onReplyPermission={onReplyPermission}
|
onReplyPermission={onReplyPermission}
|
||||||
|
onReplyQuestion={onReplyQuestion}
|
||||||
|
onRejectQuestion={onRejectQuestion}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -98,7 +106,9 @@ const TurnList = React.memo(
|
|||||||
prevProps.isTtsSupported === nextProps.isTtsSupported &&
|
prevProps.isTtsSupported === nextProps.isTtsSupported &&
|
||||||
prevProps.onRegenerate === nextProps.onRegenerate &&
|
prevProps.onRegenerate === nextProps.onRegenerate &&
|
||||||
prevProps.onCreateBranch === nextProps.onCreateBranch &&
|
prevProps.onCreateBranch === nextProps.onCreateBranch &&
|
||||||
prevProps.onReplyPermission === nextProps.onReplyPermission,
|
prevProps.onReplyPermission === nextProps.onReplyPermission &&
|
||||||
|
prevProps.onReplyQuestion === nextProps.onReplyQuestion &&
|
||||||
|
prevProps.onRejectQuestion === nextProps.onRejectQuestion,
|
||||||
);
|
);
|
||||||
|
|
||||||
TurnList.displayName = "TurnList";
|
TurnList.displayName = "TurnList";
|
||||||
@@ -231,6 +241,8 @@ export const AgentWorkspace = ({
|
|||||||
onRegenerate,
|
onRegenerate,
|
||||||
onCreateBranch,
|
onCreateBranch,
|
||||||
onReplyPermission,
|
onReplyPermission,
|
||||||
|
onReplyQuestion,
|
||||||
|
onRejectQuestion,
|
||||||
}: AgentWorkspaceProps) => {
|
}: AgentWorkspaceProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const latestAssistant = [...messages]
|
const latestAssistant = [...messages]
|
||||||
@@ -278,6 +290,8 @@ export const AgentWorkspace = ({
|
|||||||
onRegenerate={onRegenerate}
|
onRegenerate={onRegenerate}
|
||||||
onCreateBranch={onCreateBranch}
|
onCreateBranch={onCreateBranch}
|
||||||
onReplyPermission={onReplyPermission}
|
onReplyPermission={onReplyPermission}
|
||||||
|
onReplyQuestion={onReplyQuestion}
|
||||||
|
onRejectQuestion={onRejectQuestion}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{streamingMessage ? (
|
{streamingMessage ? (
|
||||||
@@ -293,6 +307,8 @@ export const AgentWorkspace = ({
|
|||||||
onRegenerate={onRegenerate}
|
onRegenerate={onRegenerate}
|
||||||
onCreateBranch={onCreateBranch}
|
onCreateBranch={onCreateBranch}
|
||||||
onReplyPermission={onReplyPermission}
|
onReplyPermission={onReplyPermission}
|
||||||
|
onReplyQuestion={onReplyQuestion}
|
||||||
|
onRejectQuestion={onRejectQuestion}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
createBranch,
|
createBranch,
|
||||||
abort,
|
abort,
|
||||||
replyPermission,
|
replyPermission,
|
||||||
|
replyQuestion,
|
||||||
|
rejectQuestion,
|
||||||
createSession,
|
createSession,
|
||||||
renameSession,
|
renameSession,
|
||||||
removeSession,
|
removeSession,
|
||||||
@@ -353,6 +355,8 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
onRegenerate={regenerate}
|
onRegenerate={regenerate}
|
||||||
onCreateBranch={createBranch}
|
onCreateBranch={createBranch}
|
||||||
onReplyPermission={replyPermission}
|
onReplyPermission={replyPermission}
|
||||||
|
onReplyQuestion={replyQuestion}
|
||||||
|
onRejectQuestion={rejectQuestion}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AgentComposer
|
<AgentComposer
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import type {
|
||||||
|
AgentQuestionRequest,
|
||||||
|
AgentTodoUpdate,
|
||||||
|
} from "@/lib/chatStream";
|
||||||
|
|
||||||
export type ChatProgress = {
|
export type ChatProgress = {
|
||||||
id: string;
|
id: string;
|
||||||
phase: string;
|
phase: string;
|
||||||
@@ -55,6 +60,8 @@ export type Message = {
|
|||||||
progress?: ChatProgress[];
|
progress?: ChatProgress[];
|
||||||
artifacts?: AgentArtifact[];
|
artifacts?: AgentArtifact[];
|
||||||
permissions?: AgentPermissionRequest[];
|
permissions?: AgentPermissionRequest[];
|
||||||
|
questions?: AgentQuestionRequest[];
|
||||||
|
todos?: AgentTodoUpdate[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
abortAgentChat,
|
abortAgentChat,
|
||||||
forkAgentChat,
|
forkAgentChat,
|
||||||
replyAgentPermission,
|
replyAgentPermission,
|
||||||
|
replyAgentQuestion,
|
||||||
resumeAgentChatStream,
|
resumeAgentChatStream,
|
||||||
streamAgentChat,
|
streamAgentChat,
|
||||||
} from "@/lib/chatStream";
|
} from "@/lib/chatStream";
|
||||||
@@ -16,6 +17,7 @@ jest.mock("@/lib/chatStream", () => ({
|
|||||||
abortAgentChat: jest.fn(async () => undefined),
|
abortAgentChat: jest.fn(async () => undefined),
|
||||||
forkAgentChat: jest.fn(async () => "forked-session"),
|
forkAgentChat: jest.fn(async () => "forked-session"),
|
||||||
replyAgentPermission: jest.fn(async () => undefined),
|
replyAgentPermission: jest.fn(async () => undefined),
|
||||||
|
replyAgentQuestion: jest.fn(async () => undefined),
|
||||||
resumeAgentChatStream: jest.fn(async () => undefined),
|
resumeAgentChatStream: jest.fn(async () => undefined),
|
||||||
streamAgentChat: jest.fn(async () => undefined),
|
streamAgentChat: jest.fn(async () => undefined),
|
||||||
}));
|
}));
|
||||||
@@ -53,11 +55,13 @@ describe("useAgentChatSession", () => {
|
|||||||
jest.mocked(abortAgentChat).mockReset();
|
jest.mocked(abortAgentChat).mockReset();
|
||||||
jest.mocked(forkAgentChat).mockReset();
|
jest.mocked(forkAgentChat).mockReset();
|
||||||
jest.mocked(replyAgentPermission).mockReset();
|
jest.mocked(replyAgentPermission).mockReset();
|
||||||
|
jest.mocked(replyAgentQuestion).mockReset();
|
||||||
jest.mocked(resumeAgentChatStream).mockReset();
|
jest.mocked(resumeAgentChatStream).mockReset();
|
||||||
jest.mocked(streamAgentChat).mockReset();
|
jest.mocked(streamAgentChat).mockReset();
|
||||||
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
|
jest.mocked(abortAgentChat).mockImplementation(async () => undefined);
|
||||||
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
|
jest.mocked(forkAgentChat).mockImplementation(async () => "forked-session");
|
||||||
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
|
jest.mocked(replyAgentPermission).mockImplementation(async () => undefined);
|
||||||
|
jest.mocked(replyAgentQuestion).mockImplementation(async () => undefined);
|
||||||
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
||||||
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
|
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
|
||||||
deleteChatSession.mockImplementation(async () => undefined);
|
deleteChatSession.mockImplementation(async () => undefined);
|
||||||
@@ -333,6 +337,337 @@ describe("useAgentChatSession", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("applies question responses to the message that owns the request", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "session-streaming",
|
||||||
|
title: "运行中",
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 2,
|
||||||
|
isStreaming: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
onEvent({
|
||||||
|
type: "state",
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
messages: [
|
||||||
|
{ id: "u1", role: "user", content: "继续分析" },
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "需要确认",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
requestId: "q-1",
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "范围",
|
||||||
|
question: "选择范围",
|
||||||
|
options: [],
|
||||||
|
custom: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: 123,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: "a2", role: "assistant", content: "后续消息" },
|
||||||
|
],
|
||||||
|
isStreaming: true,
|
||||||
|
runStatus: "running",
|
||||||
|
});
|
||||||
|
onEvent({
|
||||||
|
type: "question_response",
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
requestId: "q-1",
|
||||||
|
answers: [["城区"]],
|
||||||
|
rejected: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
expect(result.current.messages[1].questions?.[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
requestId: "q-1",
|
||||||
|
status: "answered",
|
||||||
|
answers: [["城区"]],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.current.messages[2].questions).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates question requests across assistant messages", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "session-streaming",
|
||||||
|
title: "运行中",
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 2,
|
||||||
|
isStreaming: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
onEvent({
|
||||||
|
type: "state",
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
messages: [
|
||||||
|
{ id: "u1", role: "user", content: "继续分析" },
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "需要确认",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
requestId: "question-1",
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "测试问题",
|
||||||
|
question: "你觉得这个 question 工具好用吗?",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "非常好用",
|
||||||
|
description: "交互清晰,选项方便",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tool: {
|
||||||
|
messageID: "message-1",
|
||||||
|
callID: "call-1",
|
||||||
|
},
|
||||||
|
createdAt: 123,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: "a2", role: "assistant", content: "后续消息" },
|
||||||
|
],
|
||||||
|
isStreaming: true,
|
||||||
|
runStatus: "running",
|
||||||
|
});
|
||||||
|
onEvent({
|
||||||
|
type: "question_request",
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
requestId: "call-1",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "测试问题",
|
||||||
|
question: "你觉得这个 question 工具好用吗?",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "非常好用",
|
||||||
|
description: "交互清晰,选项方便",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tool: {
|
||||||
|
messageID: "message-1",
|
||||||
|
callID: "call-1",
|
||||||
|
},
|
||||||
|
createdAt: 456,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
const allQuestions = result.current.messages.flatMap(
|
||||||
|
(message) => message.questions ?? [],
|
||||||
|
);
|
||||||
|
expect(allQuestions).toHaveLength(1);
|
||||||
|
expect(result.current.messages[1].questions?.[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
requestId: "question-1",
|
||||||
|
tool: expect.objectContaining({ callID: "call-1" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.current.messages[2].questions).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the actionable question request id when a tool-part duplicate arrives later", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "session-streaming",
|
||||||
|
title: "运行中",
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 2,
|
||||||
|
isStreaming: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
onEvent({
|
||||||
|
type: "state",
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
messages: [
|
||||||
|
{ id: "u1", role: "user", content: "继续分析" },
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "需要确认",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
requestId: "question-1",
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "测试问题",
|
||||||
|
question: "你觉得这个 question 工具好用吗?",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "非常好用",
|
||||||
|
description: "交互清晰,选项方便",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tool: {
|
||||||
|
messageID: "message-1",
|
||||||
|
callID: "call-1",
|
||||||
|
},
|
||||||
|
createdAt: 123,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isStreaming: true,
|
||||||
|
runStatus: "running",
|
||||||
|
});
|
||||||
|
onEvent({
|
||||||
|
type: "question_request",
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
requestId: "call-1",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "测试问题",
|
||||||
|
question: "你觉得这个 question 工具好用吗?",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "非常好用",
|
||||||
|
description: "交互清晰,选项方便",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tool: {
|
||||||
|
messageID: "message-1",
|
||||||
|
callID: "call-1",
|
||||||
|
},
|
||||||
|
createdAt: 456,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
const allQuestions = result.current.messages.flatMap(
|
||||||
|
(message) => message.questions ?? [],
|
||||||
|
);
|
||||||
|
expect(allQuestions).toHaveLength(1);
|
||||||
|
expect(allQuestions[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
requestId: "question-1",
|
||||||
|
tool: expect.objectContaining({ callID: "call-1" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates persisted duplicate questions from state events", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "session-streaming",
|
||||||
|
title: "运行中",
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 2,
|
||||||
|
isStreaming: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const duplicateQuestion = {
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "测试问题",
|
||||||
|
question: "你觉得这个 question 工具好用吗?",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: "非常好用",
|
||||||
|
description: "交互清晰,选项方便",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tool: {
|
||||||
|
messageID: "message-1",
|
||||||
|
callID: "call-1",
|
||||||
|
},
|
||||||
|
createdAt: 123,
|
||||||
|
status: "pending" as const,
|
||||||
|
};
|
||||||
|
jest.mocked(resumeAgentChatStream).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
onEvent({
|
||||||
|
type: "state",
|
||||||
|
sessionId: "session-loaded",
|
||||||
|
messages: [
|
||||||
|
{ id: "u1", role: "user", content: "继续分析" },
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "需要确认",
|
||||||
|
questions: [{ ...duplicateQuestion, requestId: "question-1" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "a2",
|
||||||
|
role: "assistant",
|
||||||
|
content: "后续消息",
|
||||||
|
questions: [{ ...duplicateQuestion, requestId: "call-1" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isStreaming: true,
|
||||||
|
runStatus: "running",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
result.current.messages.flatMap((message) => message.questions ?? []),
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(result.current.messages[1].questions).toHaveLength(1);
|
||||||
|
expect(result.current.messages[2].questions).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("aborts a resumed streaming session through the backend abort endpoint", async () => {
|
it("aborts a resumed streaming session through the backend abort endpoint", async () => {
|
||||||
listChatSessions.mockResolvedValue([
|
listChatSessions.mockResolvedValue([
|
||||||
{
|
{
|
||||||
@@ -433,6 +768,23 @@ describe("useAgentChatSession", () => {
|
|||||||
title: "开始分析",
|
title: "开始分析",
|
||||||
startedAt: 1000,
|
startedAt: 1000,
|
||||||
} satisfies StreamEvent);
|
} satisfies StreamEvent);
|
||||||
|
onEvent({
|
||||||
|
type: "todo_update",
|
||||||
|
sessionId: "session-1",
|
||||||
|
todos: [
|
||||||
|
{
|
||||||
|
id: "todo-1",
|
||||||
|
content: "分析水位",
|
||||||
|
status: "in_progress",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "todo-2",
|
||||||
|
content: "生成建议",
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: 1001,
|
||||||
|
} satisfies StreamEvent);
|
||||||
|
|
||||||
signal?.addEventListener("abort", () => {
|
signal?.addEventListener("abort", () => {
|
||||||
reject(new Error("aborted"));
|
reject(new Error("aborted"));
|
||||||
@@ -474,6 +826,22 @@ describe("useAgentChatSession", () => {
|
|||||||
endedAt: expect.any(Number),
|
endedAt: expect.any(Number),
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
todos: [
|
||||||
|
expect.objectContaining({
|
||||||
|
todos: [
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "todo-1",
|
||||||
|
status: "cancelled",
|
||||||
|
updatedAt: expect.any(Number),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "todo-2",
|
||||||
|
status: "cancelled",
|
||||||
|
updatedAt: expect.any(Number),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(abortAgentChat).toHaveBeenCalledWith("session-1");
|
expect(abortAgentChat).toHaveBeenCalledWith("session-1");
|
||||||
|
|||||||
@@ -5,13 +5,17 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
abortAgentChat,
|
abortAgentChat,
|
||||||
forkAgentChat,
|
forkAgentChat,
|
||||||
|
rejectAgentQuestion,
|
||||||
replyAgentPermission,
|
replyAgentPermission,
|
||||||
|
replyAgentQuestion,
|
||||||
resumeAgentChatStream,
|
resumeAgentChatStream,
|
||||||
streamAgentChat,
|
streamAgentChat,
|
||||||
} from "@/lib/chatStream";
|
} from "@/lib/chatStream";
|
||||||
import type {
|
import type {
|
||||||
AgentApprovalMode,
|
AgentApprovalMode,
|
||||||
AgentModel,
|
AgentModel,
|
||||||
|
AgentQuestionRequest,
|
||||||
|
AgentTodoUpdate,
|
||||||
PermissionReply,
|
PermissionReply,
|
||||||
StreamEvent,
|
StreamEvent,
|
||||||
} from "@/lib/chatStream";
|
} from "@/lib/chatStream";
|
||||||
@@ -135,6 +139,20 @@ const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cancelRunningTodos = (todos: AgentTodoUpdate[] | undefined) =>
|
||||||
|
todos?.map((todoUpdate) => ({
|
||||||
|
...todoUpdate,
|
||||||
|
todos: todoUpdate.todos.map((todo) =>
|
||||||
|
todo.status === "pending" || todo.status === "in_progress"
|
||||||
|
? {
|
||||||
|
...todo,
|
||||||
|
status: "cancelled" as const,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
: todo,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
const upsertPermission = (
|
const upsertPermission = (
|
||||||
permissions: AgentPermissionRequest[] | undefined,
|
permissions: AgentPermissionRequest[] | undefined,
|
||||||
event: StreamEvent & { type: "permission_request" },
|
event: StreamEvent & { type: "permission_request" },
|
||||||
@@ -170,12 +188,192 @@ const toPermissionStatus = (reply: PermissionReply): AgentPermissionRequest["sta
|
|||||||
return "rejected";
|
return "rejected";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isActionableQuestionRequest = (question: {
|
||||||
|
requestId: string;
|
||||||
|
tool?: AgentQuestionRequest["tool"];
|
||||||
|
}) => Boolean(question.requestId && question.requestId !== question.tool?.callID);
|
||||||
|
|
||||||
|
const toQuestionRequest = (
|
||||||
|
event: StreamEvent & { type: "question_request" },
|
||||||
|
status: AgentQuestionRequest["status"] = "pending",
|
||||||
|
): AgentQuestionRequest => ({
|
||||||
|
requestId: event.requestId,
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
questions: event.questions,
|
||||||
|
tool: event.tool,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getQuestionContentSignature = (
|
||||||
|
questions: AgentQuestionRequest["questions"],
|
||||||
|
) =>
|
||||||
|
JSON.stringify(
|
||||||
|
questions.map((question) => ({
|
||||||
|
header: question.header,
|
||||||
|
question: question.question,
|
||||||
|
options: question.options.map((option) => ({
|
||||||
|
label: option.label,
|
||||||
|
description: option.description,
|
||||||
|
})),
|
||||||
|
multiple: question.multiple ?? false,
|
||||||
|
custom: question.custom ?? false,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSameQuestionRequest = (
|
||||||
|
question: AgentQuestionRequest,
|
||||||
|
event: StreamEvent & { type: "question_request" },
|
||||||
|
) => {
|
||||||
|
if (question.requestId === event.requestId) return true;
|
||||||
|
if (question.tool?.callID && event.tool?.callID) {
|
||||||
|
return question.tool.callID === event.tool.callID;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
question.status === "pending" &&
|
||||||
|
question.sessionId === event.sessionId &&
|
||||||
|
getQuestionContentSignature(question.questions) ===
|
||||||
|
getQuestionContentSignature(event.questions)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSameQuestionPair = (
|
||||||
|
left: AgentQuestionRequest,
|
||||||
|
right: AgentQuestionRequest,
|
||||||
|
) => {
|
||||||
|
if (left.requestId === right.requestId) return true;
|
||||||
|
if (left.tool?.callID && right.tool?.callID) {
|
||||||
|
return left.tool.callID === right.tool.callID;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
left.status === "pending" &&
|
||||||
|
right.status === "pending" &&
|
||||||
|
left.sessionId === right.sessionId &&
|
||||||
|
getQuestionContentSignature(left.questions) ===
|
||||||
|
getQuestionContentSignature(right.questions)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dedupeQuestionsAcrossMessages = (messages: Message[]) => {
|
||||||
|
const seen: AgentQuestionRequest[] = [];
|
||||||
|
let changed = false;
|
||||||
|
const nextMessages = messages.map((message) => {
|
||||||
|
if (!message.questions?.length) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
const nextQuestions = message.questions.filter((question) => {
|
||||||
|
if (seen.some((existing) => isSameQuestionPair(existing, question))) {
|
||||||
|
changed = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.push(question);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (nextQuestions.length === message.questions.length) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
questions: nextQuestions.length ? nextQuestions : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return changed ? nextMessages : messages;
|
||||||
|
};
|
||||||
|
|
||||||
|
const upsertQuestionAcrossMessages = (
|
||||||
|
messages: Message[],
|
||||||
|
event: StreamEvent & { type: "question_request" },
|
||||||
|
assistantMessageId: string,
|
||||||
|
) => {
|
||||||
|
let existing: AgentQuestionRequest | undefined;
|
||||||
|
for (const message of messages) {
|
||||||
|
const match = message.questions?.find((question) =>
|
||||||
|
isSameQuestionRequest(question, event),
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
existing = match;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingStatus: AgentQuestionRequest["status"] | undefined =
|
||||||
|
existing?.status === "submitting" ? "submitting" : undefined;
|
||||||
|
const nextQuestion =
|
||||||
|
existing &&
|
||||||
|
isActionableQuestionRequest(existing) &&
|
||||||
|
!isActionableQuestionRequest(event)
|
||||||
|
? {
|
||||||
|
...existing,
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
questions: event.questions,
|
||||||
|
tool: event.tool ?? existing.tool,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
status: existingStatus ?? existing.status,
|
||||||
|
}
|
||||||
|
: toQuestionRequest(event, existingStatus ?? "pending");
|
||||||
|
const targetMessageId = existing
|
||||||
|
? messages.find((message) =>
|
||||||
|
message.questions?.some((question) => isSameQuestionRequest(question, event)),
|
||||||
|
)?.id ?? assistantMessageId
|
||||||
|
: assistantMessageId;
|
||||||
|
|
||||||
|
return messages.map((message) => {
|
||||||
|
const filteredQuestions = message.questions?.filter(
|
||||||
|
(question) => !isSameQuestionRequest(question, event),
|
||||||
|
);
|
||||||
|
if (message.id !== targetMessageId) {
|
||||||
|
return filteredQuestions?.length === message.questions?.length
|
||||||
|
? message
|
||||||
|
: {
|
||||||
|
...message,
|
||||||
|
questions: filteredQuestions?.length ? filteredQuestions : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextQuestions = [...(filteredQuestions ?? []), nextQuestion];
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
questions: nextQuestions,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyQuestionResponse = (
|
||||||
|
questions: AgentQuestionRequest[] | undefined,
|
||||||
|
event: StreamEvent & { type: "question_response" },
|
||||||
|
) =>
|
||||||
|
(questions ?? []).map((question) =>
|
||||||
|
question.requestId === event.requestId
|
||||||
|
? {
|
||||||
|
...question,
|
||||||
|
status: event.rejected ? "rejected" as const : "answered" as const,
|
||||||
|
answers: event.answers ?? question.answers,
|
||||||
|
repliedAt: Date.now(),
|
||||||
|
error: undefined,
|
||||||
|
}
|
||||||
|
: question,
|
||||||
|
);
|
||||||
|
|
||||||
|
const upsertTodoUpdate = (
|
||||||
|
todos: AgentTodoUpdate[] | undefined,
|
||||||
|
event: StreamEvent & { type: "todo_update" },
|
||||||
|
) => [
|
||||||
|
{
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
messageId: event.messageId,
|
||||||
|
todos: event.todos,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
|
const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
|
||||||
const completedProgress = completeRunningProgress(message.progress);
|
const completedProgress = completeRunningProgress(message.progress);
|
||||||
|
const cancelledTodos = cancelRunningTodos(message.todos);
|
||||||
const hasVisibleOutput =
|
const hasVisibleOutput =
|
||||||
message.content.trim().length > 0 ||
|
message.content.trim().length > 0 ||
|
||||||
Boolean(message.artifacts?.length) ||
|
Boolean(message.artifacts?.length) ||
|
||||||
Boolean(completedProgress?.length);
|
Boolean(completedProgress?.length) ||
|
||||||
|
Boolean(cancelledTodos?.length);
|
||||||
|
|
||||||
if (!hasVisibleOutput) {
|
if (!hasVisibleOutput) {
|
||||||
return message;
|
return message;
|
||||||
@@ -186,6 +384,7 @@ const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
|
|||||||
content: message.content || "⚠️ **请求已中断**",
|
content: message.content || "⚠️ **请求已中断**",
|
||||||
isError: true,
|
isError: true,
|
||||||
progress: completedProgress,
|
progress: completedProgress,
|
||||||
|
todos: cancelledTodos,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -291,7 +490,7 @@ export const useAgentChatSession = ({
|
|||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
|
|
||||||
setMessages(loadedState.messages);
|
setMessages(dedupeQuestionsAcrossMessages(loadedState.messages));
|
||||||
setSessionTitle(loadedState.title);
|
setSessionTitle(loadedState.title);
|
||||||
setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false);
|
setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false);
|
||||||
setSessionId(loadedState.sessionId);
|
setSessionId(loadedState.sessionId);
|
||||||
@@ -401,7 +600,9 @@ export const useAgentChatSession = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "state") {
|
if (event.type === "state") {
|
||||||
const nextMessages = cloneMessages(event.messages as Message[]);
|
const nextMessages = dedupeQuestionsAcrossMessages(
|
||||||
|
cloneMessages(event.messages as Message[]),
|
||||||
|
);
|
||||||
messagesRef.current = nextMessages;
|
messagesRef.current = nextMessages;
|
||||||
setMessages(nextMessages);
|
setMessages(nextMessages);
|
||||||
setIsStreaming(event.isStreaming);
|
setIsStreaming(event.isStreaming);
|
||||||
@@ -502,6 +703,32 @@ export const useAgentChatSession = ({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
} else if (event.type === "question_request") {
|
||||||
|
setMessages((prev) =>
|
||||||
|
upsertQuestionAcrossMessages(prev, event, assistantMessageId),
|
||||||
|
);
|
||||||
|
} else if (event.type === "question_response") {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
message.questions?.some((question) => question.requestId === event.requestId)
|
||||||
|
? {
|
||||||
|
...message,
|
||||||
|
questions: applyQuestionResponse(message.questions, event),
|
||||||
|
}
|
||||||
|
: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (event.type === "todo_update") {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
message.id === assistantMessageId
|
||||||
|
? {
|
||||||
|
...message,
|
||||||
|
todos: upsertTodoUpdate(message.todos, event),
|
||||||
|
}
|
||||||
|
: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
} else if (event.type === "done") {
|
} else if (event.type === "done") {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((message) => {
|
prev.map((message) => {
|
||||||
@@ -531,6 +758,7 @@ export const useAgentChatSession = ({
|
|||||||
content: message.content || `⚠️ **错误:** ${event.message}`,
|
content: message.content || `⚠️ **错误:** ${event.message}`,
|
||||||
isError: true,
|
isError: true,
|
||||||
progress: completeRunningProgress(message.progress),
|
progress: completeRunningProgress(message.progress),
|
||||||
|
todos: cancelRunningTodos(message.todos),
|
||||||
}
|
}
|
||||||
: message,
|
: message,
|
||||||
),
|
),
|
||||||
@@ -621,11 +849,7 @@ export const useAgentChatSession = ({
|
|||||||
prev
|
prev
|
||||||
.map((message) =>
|
.map((message) =>
|
||||||
message.id === nextAssistantMessage.id
|
message.id === nextAssistantMessage.id
|
||||||
? {
|
? finalizeAssistantMessageAfterAbort(message)
|
||||||
...message,
|
|
||||||
content: message.content || "⚠️ **请求已中断**",
|
|
||||||
isError: true,
|
|
||||||
}
|
|
||||||
: message,
|
: message,
|
||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -635,7 +859,8 @@ export const useAgentChatSession = ({
|
|||||||
message.role === "assistant" &&
|
message.role === "assistant" &&
|
||||||
message.content.trim().length === 0 &&
|
message.content.trim().length === 0 &&
|
||||||
!(message.artifacts?.length) &&
|
!(message.artifacts?.length) &&
|
||||||
!(message.progress?.length)
|
!(message.progress?.length) &&
|
||||||
|
!(message.todos?.length)
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -766,6 +991,145 @@ export const useAgentChatSession = ({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const replyQuestion = useCallback(
|
||||||
|
async (requestId: string, answers: string[][]) => {
|
||||||
|
const target = messagesRef.current
|
||||||
|
.flatMap((message) => message.questions ?? [])
|
||||||
|
.find((question) => question.requestId === requestId);
|
||||||
|
if (!target || target.status === "submitting") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
!message.questions?.some((question) => question.requestId === requestId)
|
||||||
|
? message
|
||||||
|
: {
|
||||||
|
...message,
|
||||||
|
questions: message.questions.map((question) =>
|
||||||
|
question.requestId === requestId
|
||||||
|
? { ...question, status: "submitting", error: undefined }
|
||||||
|
: question,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await replyAgentQuestion(target.sessionId, requestId, answers);
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
!message.questions?.some((question) => question.requestId === requestId)
|
||||||
|
? message
|
||||||
|
: {
|
||||||
|
...message,
|
||||||
|
questions: message.questions.map((question) =>
|
||||||
|
question.requestId === requestId
|
||||||
|
? {
|
||||||
|
...question,
|
||||||
|
status: "answered",
|
||||||
|
answers,
|
||||||
|
repliedAt: Date.now(),
|
||||||
|
error: undefined,
|
||||||
|
}
|
||||||
|
: question,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
!message.questions?.some((question) => question.requestId === requestId)
|
||||||
|
? message
|
||||||
|
: {
|
||||||
|
...message,
|
||||||
|
questions: message.questions.map((question) =>
|
||||||
|
question.requestId === requestId
|
||||||
|
? {
|
||||||
|
...question,
|
||||||
|
status: "error",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
}
|
||||||
|
: question,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rejectQuestion = useCallback(
|
||||||
|
async (requestId: string) => {
|
||||||
|
const target = messagesRef.current
|
||||||
|
.flatMap((message) => message.questions ?? [])
|
||||||
|
.find((question) => question.requestId === requestId);
|
||||||
|
if (!target || target.status === "submitting") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
!message.questions?.some((question) => question.requestId === requestId)
|
||||||
|
? message
|
||||||
|
: {
|
||||||
|
...message,
|
||||||
|
questions: message.questions.map((question) =>
|
||||||
|
question.requestId === requestId
|
||||||
|
? { ...question, status: "submitting", error: undefined }
|
||||||
|
: question,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rejectAgentQuestion(target.sessionId, requestId);
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
!message.questions?.some((question) => question.requestId === requestId)
|
||||||
|
? message
|
||||||
|
: {
|
||||||
|
...message,
|
||||||
|
questions: message.questions.map((question) =>
|
||||||
|
question.requestId === requestId
|
||||||
|
? {
|
||||||
|
...question,
|
||||||
|
status: "rejected",
|
||||||
|
repliedAt: Date.now(),
|
||||||
|
error: undefined,
|
||||||
|
}
|
||||||
|
: question,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
!message.questions?.some((question) => question.requestId === requestId)
|
||||||
|
? message
|
||||||
|
: {
|
||||||
|
...message,
|
||||||
|
questions: message.questions.map((question) =>
|
||||||
|
question.requestId === requestId
|
||||||
|
? {
|
||||||
|
...question,
|
||||||
|
status: "error",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
}
|
||||||
|
: question,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const createSession = useCallback(() => {
|
const createSession = useCallback(() => {
|
||||||
if (isHydrating || isStreaming) return;
|
if (isHydrating || isStreaming) return;
|
||||||
|
|
||||||
@@ -1009,6 +1373,8 @@ export const useAgentChatSession = ({
|
|||||||
createBranch,
|
createBranch,
|
||||||
abort,
|
abort,
|
||||||
replyPermission,
|
replyPermission,
|
||||||
|
replyQuestion,
|
||||||
|
rejectQuestion,
|
||||||
createSession,
|
createSession,
|
||||||
renameSession,
|
renameSession,
|
||||||
removeSession,
|
removeSession,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
abortAgentChat,
|
abortAgentChat,
|
||||||
forkAgentChat,
|
forkAgentChat,
|
||||||
|
rejectAgentQuestion,
|
||||||
replyAgentPermission,
|
replyAgentPermission,
|
||||||
|
replyAgentQuestion,
|
||||||
type StreamEvent,
|
type StreamEvent,
|
||||||
resumeAgentChatStream,
|
resumeAgentChatStream,
|
||||||
streamAgentChat,
|
streamAgentChat,
|
||||||
@@ -218,6 +220,69 @@ describe("streamAgentChat", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses question request, response, and todo update events", async () => {
|
||||||
|
apiFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
body: makeStream([
|
||||||
|
'event: question_request\ndata: {"session_id":"s1","request_id":"q-1","questions":[{"header":"范围","question":"选择范围","options":[{"label":"城区","description":"中心城区"}],"multiple":false,"custom":true}],"tool":{"message_id":"m1","call_id":"c1"},"created_at":123}\n\n',
|
||||||
|
'event: question_response\ndata: {"session_id":"s1","request_id":"q-1","answers":[["城区","补充说明"]]}\n\n',
|
||||||
|
'event: todo_update\ndata: {"session_id":"s1","todos":[{"id":"t1","content":"分析水位","status":"in_progress","priority":"high","updated_at":456}],"created_at":456}\n\n',
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const events: StreamEvent[] = [];
|
||||||
|
|
||||||
|
await streamAgentChat({
|
||||||
|
message: "hi",
|
||||||
|
onEvent: (event) => events.push(event),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "question_request",
|
||||||
|
sessionId: "s1",
|
||||||
|
requestId: "q-1",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "范围",
|
||||||
|
question: "选择范围",
|
||||||
|
options: [{ label: "城区", description: "中心城区" }],
|
||||||
|
multiple: false,
|
||||||
|
custom: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tool: {
|
||||||
|
messageID: "m1",
|
||||||
|
callID: "c1",
|
||||||
|
},
|
||||||
|
createdAt: 123,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "question_response",
|
||||||
|
sessionId: "s1",
|
||||||
|
requestId: "q-1",
|
||||||
|
answers: [["城区", "补充说明"]],
|
||||||
|
rejected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "todo_update",
|
||||||
|
sessionId: "s1",
|
||||||
|
messageId: undefined,
|
||||||
|
todos: [
|
||||||
|
{
|
||||||
|
id: "t1",
|
||||||
|
content: "分析水位",
|
||||||
|
status: "in_progress",
|
||||||
|
priority: "high",
|
||||||
|
createdAt: undefined,
|
||||||
|
updatedAt: 456,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: 456,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("emits error when response is not ok", async () => {
|
it("emits error when response is not ok", async () => {
|
||||||
apiFetch.mockResolvedValue({
|
apiFetch.mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -314,6 +379,41 @@ describe("streamAgentChat", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("calls question reply and reject endpoints", async () => {
|
||||||
|
apiFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 202,
|
||||||
|
text: async () => "",
|
||||||
|
});
|
||||||
|
|
||||||
|
await replyAgentQuestion("s1", "q-1", [["城区"]]);
|
||||||
|
await rejectAgentQuestion("s1", "q-2");
|
||||||
|
|
||||||
|
expect(apiFetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("/api/v1/agent/chat/question/q-1/reply"),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
projectHeaderMode: "include",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: "s1",
|
||||||
|
answers: [["城区"]],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(apiFetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("/api/v1/agent/chat/question/q-2/reject"),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
projectHeaderMode: "include",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: "s1",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("calls fork endpoint and returns new session id", async () => {
|
it("calls fork endpoint and returns new session id", async () => {
|
||||||
apiFetch.mockResolvedValue({
|
apiFetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -8,6 +8,53 @@ export type AgentModel =
|
|||||||
export type PermissionReply = "once" | "always" | "reject";
|
export type PermissionReply = "once" | "always" | "reject";
|
||||||
export type AgentApprovalMode = "request" | "always";
|
export type AgentApprovalMode = "request" | "always";
|
||||||
|
|
||||||
|
export type AgentQuestionStatus =
|
||||||
|
| "pending"
|
||||||
|
| "submitting"
|
||||||
|
| "answered"
|
||||||
|
| "rejected"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
export type AgentQuestionRequest = {
|
||||||
|
requestId: string;
|
||||||
|
sessionId: string;
|
||||||
|
questions: Array<{
|
||||||
|
header: string;
|
||||||
|
question: string;
|
||||||
|
options: Array<{
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
multiple?: boolean;
|
||||||
|
custom?: boolean;
|
||||||
|
}>;
|
||||||
|
tool?: {
|
||||||
|
messageID: string;
|
||||||
|
callID: string;
|
||||||
|
};
|
||||||
|
createdAt: number;
|
||||||
|
repliedAt?: number;
|
||||||
|
status: AgentQuestionStatus;
|
||||||
|
answers?: string[][];
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentTodoItem = {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
status: "pending" | "in_progress" | "completed" | "cancelled";
|
||||||
|
priority?: "low" | "medium" | "high";
|
||||||
|
createdAt?: number;
|
||||||
|
updatedAt?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentTodoUpdate = {
|
||||||
|
sessionId: string;
|
||||||
|
messageId?: string;
|
||||||
|
todos: AgentTodoItem[];
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type StreamEvent =
|
export type StreamEvent =
|
||||||
| {
|
| {
|
||||||
type: "state";
|
type: "state";
|
||||||
@@ -64,6 +111,28 @@ export type StreamEvent =
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
reply: PermissionReply;
|
reply: PermissionReply;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "question_request";
|
||||||
|
sessionId: string;
|
||||||
|
requestId: string;
|
||||||
|
questions: AgentQuestionRequest["questions"];
|
||||||
|
tool?: AgentQuestionRequest["tool"];
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "question_response";
|
||||||
|
sessionId: string;
|
||||||
|
requestId: string;
|
||||||
|
answers?: string[][];
|
||||||
|
rejected?: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "todo_update";
|
||||||
|
sessionId: string;
|
||||||
|
messageId?: string;
|
||||||
|
todos: AgentTodoItem[];
|
||||||
|
createdAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StreamOptions = {
|
type StreamOptions = {
|
||||||
@@ -125,6 +194,80 @@ const resolveToolParams = (
|
|||||||
return isObjectRecord(params) ? params : {};
|
return isObjectRecord(params) ? params : {};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeQuestionList = (value: unknown): AgentQuestionRequest["questions"] => {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.filter(isObjectRecord)
|
||||||
|
.map((question) => ({
|
||||||
|
header: typeof question.header === "string" ? question.header : "",
|
||||||
|
question: typeof question.question === "string" ? question.question : "",
|
||||||
|
options: Array.isArray(question.options)
|
||||||
|
? question.options.filter(isObjectRecord).map((option) => ({
|
||||||
|
label: typeof option.label === "string" ? option.label : "",
|
||||||
|
description:
|
||||||
|
typeof option.description === "string" ? option.description : "",
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
multiple: typeof question.multiple === "boolean" ? question.multiple : undefined,
|
||||||
|
custom: typeof question.custom === "boolean" ? question.custom : undefined,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeAnswers = (value: unknown): string[][] | undefined => {
|
||||||
|
if (!Array.isArray(value)) return undefined;
|
||||||
|
return value.map((answer) =>
|
||||||
|
Array.isArray(answer)
|
||||||
|
? answer.filter((item): item is string => typeof item === "string")
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeQuestionTool = (value: unknown): AgentQuestionRequest["tool"] => {
|
||||||
|
if (!isObjectRecord(value)) return undefined;
|
||||||
|
const messageID =
|
||||||
|
typeof value.messageID === "string"
|
||||||
|
? value.messageID
|
||||||
|
: typeof value.message_id === "string"
|
||||||
|
? value.message_id
|
||||||
|
: undefined;
|
||||||
|
const callID =
|
||||||
|
typeof value.callID === "string"
|
||||||
|
? value.callID
|
||||||
|
: typeof value.call_id === "string"
|
||||||
|
? value.call_id
|
||||||
|
: undefined;
|
||||||
|
return messageID && callID ? { messageID, callID } : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeTodoStatus = (value: unknown): AgentTodoItem["status"] => {
|
||||||
|
if (value === "in_progress" || value === "completed" || value === "cancelled") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return "pending";
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeTodoPriority = (value: unknown): AgentTodoItem["priority"] => {
|
||||||
|
if (value === "low" || value === "medium" || value === "high") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeTodos = (value: unknown): AgentTodoItem[] => {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter(isObjectRecord).map((todo, index) => ({
|
||||||
|
id:
|
||||||
|
typeof todo.id === "string" && todo.id.trim()
|
||||||
|
? todo.id
|
||||||
|
: `todo-${index}`,
|
||||||
|
content: typeof todo.content === "string" ? todo.content : "",
|
||||||
|
status: normalizeTodoStatus(todo.status),
|
||||||
|
priority: normalizeTodoPriority(todo.priority),
|
||||||
|
createdAt: typeof todo.created_at === "number" ? todo.created_at : undefined,
|
||||||
|
updatedAt: typeof todo.updated_at === "number" ? todo.updated_at : undefined,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const emitParsedStreamEvent = (
|
const emitParsedStreamEvent = (
|
||||||
event: string,
|
event: string,
|
||||||
data: string,
|
data: string,
|
||||||
@@ -158,6 +301,11 @@ const emitParsedStreamEvent = (
|
|||||||
always?: unknown;
|
always?: unknown;
|
||||||
created_at?: number;
|
created_at?: number;
|
||||||
reply?: PermissionReply;
|
reply?: PermissionReply;
|
||||||
|
questions?: unknown;
|
||||||
|
answers?: unknown;
|
||||||
|
rejected?: boolean;
|
||||||
|
message_id?: string;
|
||||||
|
todos?: unknown;
|
||||||
};
|
};
|
||||||
if (event === "state") {
|
if (event === "state") {
|
||||||
onEvent({
|
onEvent({
|
||||||
@@ -244,6 +392,31 @@ const emitParsedStreamEvent = (
|
|||||||
requestId: parsed.request_id ?? "",
|
requestId: parsed.request_id ?? "",
|
||||||
reply: parsed.reply ?? "reject",
|
reply: parsed.reply ?? "reject",
|
||||||
});
|
});
|
||||||
|
} else if (event === "question_request") {
|
||||||
|
onEvent({
|
||||||
|
type: "question_request",
|
||||||
|
sessionId: parsed.session_id ?? "",
|
||||||
|
requestId: parsed.request_id ?? "",
|
||||||
|
questions: normalizeQuestionList(parsed.questions),
|
||||||
|
tool: normalizeQuestionTool(parsed.tool),
|
||||||
|
createdAt: parsed.created_at ?? Date.now(),
|
||||||
|
});
|
||||||
|
} else if (event === "question_response") {
|
||||||
|
onEvent({
|
||||||
|
type: "question_response",
|
||||||
|
sessionId: parsed.session_id ?? "",
|
||||||
|
requestId: parsed.request_id ?? "",
|
||||||
|
answers: normalizeAnswers(parsed.answers),
|
||||||
|
rejected: parsed.rejected === true,
|
||||||
|
});
|
||||||
|
} else if (event === "todo_update") {
|
||||||
|
onEvent({
|
||||||
|
type: "todo_update",
|
||||||
|
sessionId: parsed.session_id ?? "",
|
||||||
|
messageId: parsed.message_id,
|
||||||
|
todos: normalizeTodos(parsed.todos),
|
||||||
|
createdAt: parsed.created_at ?? Date.now(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
onEvent({
|
onEvent({
|
||||||
@@ -443,6 +616,60 @@ export const replyAgentPermission = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const replyAgentQuestion = async (
|
||||||
|
sessionId: string,
|
||||||
|
requestId: string,
|
||||||
|
answers: string[][],
|
||||||
|
) => {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${config.AGENT_URL}/api/v1/agent/chat/question/${encodeURIComponent(requestId)}/reply`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: sessionId,
|
||||||
|
answers,
|
||||||
|
}),
|
||||||
|
projectHeaderMode: "include",
|
||||||
|
userHeaderMode: "include",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.text();
|
||||||
|
throw new Error(detail || `question reply failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rejectAgentQuestion = async (
|
||||||
|
sessionId: string,
|
||||||
|
requestId: string,
|
||||||
|
) => {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${config.AGENT_URL}/api/v1/agent/chat/question/${encodeURIComponent(requestId)}/reject`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: sessionId,
|
||||||
|
}),
|
||||||
|
projectHeaderMode: "include",
|
||||||
|
userHeaderMode: "include",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.text();
|
||||||
|
throw new Error(detail || `question reject failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const forkAgentChat = async (sessionId: string | undefined, keepMessageCount: number) => {
|
export const forkAgentChat = async (sessionId: string | undefined, keepMessageCount: number) => {
|
||||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/fork`, {
|
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/fork`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
Reference in New Issue
Block a user