fix(chat): update question abort state
This commit is contained in:
@@ -51,6 +51,7 @@ import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded";
|
|||||||
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
||||||
import BlockRounded from "@mui/icons-material/BlockRounded";
|
import BlockRounded from "@mui/icons-material/BlockRounded";
|
||||||
import PushPinRounded from "@mui/icons-material/PushPinRounded";
|
import PushPinRounded from "@mui/icons-material/PushPinRounded";
|
||||||
|
import EditNoteRounded from "@mui/icons-material/EditNoteRounded";
|
||||||
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 AssignmentTurnedInRounded from "@mui/icons-material/AssignmentTurnedInRounded";
|
||||||
@@ -713,26 +714,30 @@ const QuestionRequestCard = ({
|
|||||||
const isSubmitting = questionRequest.status === "submitting";
|
const isSubmitting = questionRequest.status === "submitting";
|
||||||
const statusColor = getQuestionStatusColor(questionRequest.status, theme);
|
const statusColor = getQuestionStatusColor(questionRequest.status, theme);
|
||||||
const [selected, setSelected] = React.useState<Record<number, string[]>>({});
|
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 [custom, setCustom] = React.useState<Record<number, string>>({});
|
||||||
|
|
||||||
const answers = React.useMemo(
|
const answers = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
questionRequest.questions.map((question, index) => {
|
questionRequest.questions.map((question, index) => {
|
||||||
const selectedAnswers = selected[index] ?? [];
|
const selectedAnswers = selected[index] ?? [];
|
||||||
|
const isCustomSelected =
|
||||||
|
customSelected[index] === true ||
|
||||||
|
(question.custom !== false && question.options.length === 0);
|
||||||
const customAnswer = custom[index]?.trim();
|
const customAnswer = custom[index]?.trim();
|
||||||
return customAnswer ? [...selectedAnswers, customAnswer] : selectedAnswers;
|
return isCustomSelected && customAnswer
|
||||||
|
? [...selectedAnswers, customAnswer]
|
||||||
|
: selectedAnswers;
|
||||||
}),
|
}),
|
||||||
[custom, questionRequest.questions, selected],
|
[custom, customSelected, questionRequest.questions, selected],
|
||||||
);
|
);
|
||||||
|
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
isEditable &&
|
isEditable &&
|
||||||
questionRequest.questions.length > 0 &&
|
questionRequest.questions.length > 0 &&
|
||||||
questionRequest.questions.every((question, index) => {
|
questionRequest.questions.every((_, index) => {
|
||||||
const answer = answers[index] ?? [];
|
const answer = answers[index] ?? [];
|
||||||
const hasInput = answer.some((item) => item.trim().length > 0);
|
return answer.some((item) => item.trim().length > 0);
|
||||||
const canAnswer = question.options.length > 0 || question.custom === true;
|
|
||||||
return canAnswer && hasInput;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const answerSummary = (questionRequest.answers ?? [])
|
const answerSummary = (questionRequest.answers ?? [])
|
||||||
@@ -809,12 +814,22 @@ const QuestionRequestCard = ({
|
|||||||
<Stack spacing={1.3} sx={{ px: 1.5, py: 1.35, pl: 1.75 }}>
|
<Stack spacing={1.3} sx={{ px: 1.5, py: 1.35, pl: 1.75 }}>
|
||||||
{questionRequest.questions.map((question, index) => {
|
{questionRequest.questions.map((question, index) => {
|
||||||
const selectedAnswers = selected[index] ?? [];
|
const selectedAnswers = selected[index] ?? [];
|
||||||
|
const isCustomEnabled = question.custom !== false;
|
||||||
|
const isCustomSelected =
|
||||||
|
customSelected[index] === true ||
|
||||||
|
(isCustomEnabled && question.options.length === 0);
|
||||||
const setQuestionAnswers = (nextAnswers: string[]) => {
|
const setQuestionAnswers = (nextAnswers: string[]) => {
|
||||||
setSelected((current) => ({
|
setSelected((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[index]: nextAnswers,
|
[index]: nextAnswers,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
const setQuestionCustomSelected = (checked: boolean) => {
|
||||||
|
setCustomSelected((current) => ({
|
||||||
|
...current,
|
||||||
|
[index]: checked,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -884,7 +899,10 @@ const QuestionRequestCard = ({
|
|||||||
size="small"
|
size="small"
|
||||||
variant={checked ? "contained" : "outlined"}
|
variant={checked ? "contained" : "outlined"}
|
||||||
disabled={!isEditable || isSubmitting}
|
disabled={!isEditable || isSubmitting}
|
||||||
onClick={() => setQuestionAnswers([option.label])}
|
onClick={() => {
|
||||||
|
setQuestionAnswers([option.label]);
|
||||||
|
setQuestionCustomSelected(false);
|
||||||
|
}}
|
||||||
startIcon={
|
startIcon={
|
||||||
checked ? (
|
checked ? (
|
||||||
<CheckCircleRounded fontSize="small" />
|
<CheckCircleRounded fontSize="small" />
|
||||||
@@ -921,28 +939,145 @@ const QuestionRequestCard = ({
|
|||||||
</Button>
|
</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>
|
</Stack>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{question.custom ? (
|
<Collapse in={isCustomEnabled && isCustomSelected} timeout="auto" unmountOnExit>
|
||||||
<TextField
|
<Box
|
||||||
multiline
|
sx={{
|
||||||
minRows={2}
|
mt: 0.85,
|
||||||
maxRows={5}
|
px: 1.15,
|
||||||
fullWidth
|
py: 0.85,
|
||||||
size="small"
|
borderRadius: 2.5,
|
||||||
disabled={!isEditable || isSubmitting}
|
bgcolor: alpha("#fff", 0.62),
|
||||||
value={custom[index] ?? ""}
|
border: `1px solid ${alpha("#fff", 0.82)}`,
|
||||||
onChange={(event) =>
|
boxShadow: `0 8px 22px ${alpha("#000", 0.045)}, 0 0 0 1px ${alpha("#0288d1", 0.05)} inset`,
|
||||||
setCustom((current) => ({
|
backdropFilter: "blur(18px)",
|
||||||
...current,
|
}}
|
||||||
[index]: event.target.value,
|
>
|
||||||
}))
|
<TextField
|
||||||
}
|
multiline
|
||||||
placeholder="补充说明"
|
minRows={2}
|
||||||
sx={{ mt: 1 }}
|
maxRows={5}
|
||||||
/>
|
fullWidth
|
||||||
) : null}
|
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>
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -785,6 +785,29 @@ describe("useAgentChatSession", () => {
|
|||||||
],
|
],
|
||||||
createdAt: 1001,
|
createdAt: 1001,
|
||||||
} satisfies StreamEvent);
|
} satisfies StreamEvent);
|
||||||
|
onEvent({
|
||||||
|
type: "permission_request",
|
||||||
|
sessionId: "session-1",
|
||||||
|
requestId: "perm-abort",
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["npm test"],
|
||||||
|
metadata: { command: "npm test" },
|
||||||
|
always: ["npm test"],
|
||||||
|
createdAt: 1002,
|
||||||
|
} satisfies StreamEvent);
|
||||||
|
onEvent({
|
||||||
|
type: "question_request",
|
||||||
|
sessionId: "session-1",
|
||||||
|
requestId: "question-abort",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "范围",
|
||||||
|
question: "请选择范围",
|
||||||
|
options: [{ label: "城区", description: "中心城区" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: 1003,
|
||||||
|
} satisfies StreamEvent);
|
||||||
|
|
||||||
signal?.addEventListener("abort", () => {
|
signal?.addEventListener("abort", () => {
|
||||||
reject(new Error("aborted"));
|
reject(new Error("aborted"));
|
||||||
@@ -842,6 +865,22 @@ describe("useAgentChatSession", () => {
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
permissions: [
|
||||||
|
expect.objectContaining({
|
||||||
|
requestId: "perm-abort",
|
||||||
|
status: "rejected",
|
||||||
|
repliedAt: expect.any(Number),
|
||||||
|
error: undefined,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
questions: [
|
||||||
|
expect.objectContaining({
|
||||||
|
requestId: "question-abort",
|
||||||
|
status: "rejected",
|
||||||
|
repliedAt: expect.any(Number),
|
||||||
|
error: undefined,
|
||||||
|
}),
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(abortAgentChat).toHaveBeenCalledWith("session-1");
|
expect(abortAgentChat).toHaveBeenCalledWith("session-1");
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ const getQuestionContentSignature = (
|
|||||||
description: option.description,
|
description: option.description,
|
||||||
})),
|
})),
|
||||||
multiple: question.multiple ?? false,
|
multiple: question.multiple ?? false,
|
||||||
custom: question.custom ?? false,
|
custom: question.custom !== false,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -366,12 +366,64 @@ const upsertTodoUpdate = (
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const rejectOpenPermissionsAfterAbort = (
|
||||||
|
permissions: AgentPermissionRequest[] | undefined,
|
||||||
|
) => {
|
||||||
|
if (!permissions?.length) return permissions;
|
||||||
|
let changed = false;
|
||||||
|
const nextPermissions = permissions.map((permission) => {
|
||||||
|
if (
|
||||||
|
permission.status !== "pending" &&
|
||||||
|
permission.status !== "submitting" &&
|
||||||
|
permission.status !== "error"
|
||||||
|
) {
|
||||||
|
return permission;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
return {
|
||||||
|
...permission,
|
||||||
|
status: "rejected" as const,
|
||||||
|
repliedAt: Date.now(),
|
||||||
|
error: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return changed ? nextPermissions : permissions;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectOpenQuestionsAfterAbort = (
|
||||||
|
questions: AgentQuestionRequest[] | undefined,
|
||||||
|
) => {
|
||||||
|
if (!questions?.length) return questions;
|
||||||
|
let changed = false;
|
||||||
|
const nextQuestions = questions.map((question) => {
|
||||||
|
if (
|
||||||
|
question.status !== "pending" &&
|
||||||
|
question.status !== "submitting" &&
|
||||||
|
question.status !== "error"
|
||||||
|
) {
|
||||||
|
return question;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
return {
|
||||||
|
...question,
|
||||||
|
status: "rejected" as const,
|
||||||
|
repliedAt: Date.now(),
|
||||||
|
error: undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return changed ? nextQuestions : questions;
|
||||||
|
};
|
||||||
|
|
||||||
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 cancelledTodos = cancelRunningTodos(message.todos);
|
||||||
|
const rejectedPermissions = rejectOpenPermissionsAfterAbort(message.permissions);
|
||||||
|
const rejectedQuestions = rejectOpenQuestionsAfterAbort(message.questions);
|
||||||
const hasVisibleOutput =
|
const hasVisibleOutput =
|
||||||
message.content.trim().length > 0 ||
|
message.content.trim().length > 0 ||
|
||||||
Boolean(message.artifacts?.length) ||
|
Boolean(message.artifacts?.length) ||
|
||||||
|
Boolean(rejectedPermissions?.length) ||
|
||||||
|
Boolean(rejectedQuestions?.length) ||
|
||||||
Boolean(completedProgress?.length) ||
|
Boolean(completedProgress?.length) ||
|
||||||
Boolean(cancelledTodos?.length);
|
Boolean(cancelledTodos?.length);
|
||||||
|
|
||||||
@@ -384,6 +436,8 @@ const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
|
|||||||
content: message.content || "⚠️ **请求已中断**",
|
content: message.content || "⚠️ **请求已中断**",
|
||||||
isError: true,
|
isError: true,
|
||||||
progress: completedProgress,
|
progress: completedProgress,
|
||||||
|
permissions: rejectedPermissions,
|
||||||
|
questions: rejectedQuestions,
|
||||||
todos: cancelledTodos,
|
todos: cancelledTodos,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user