feat: add permission request UI
This commit is contained in:
@@ -9,6 +9,9 @@ import {
|
|||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Collapse,
|
||||||
IconButton,
|
IconButton,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -42,6 +45,15 @@ import PauseRounded from "@mui/icons-material/PauseRounded";
|
|||||||
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
|
import PlayArrowRounded from "@mui/icons-material/PlayArrowRounded";
|
||||||
import StopRounded from "@mui/icons-material/StopRounded";
|
import StopRounded from "@mui/icons-material/StopRounded";
|
||||||
import SendRounded from "@mui/icons-material/SendRounded";
|
import SendRounded from "@mui/icons-material/SendRounded";
|
||||||
|
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
|
||||||
|
import TerminalRounded from "@mui/icons-material/TerminalRounded";
|
||||||
|
import FolderOpenRounded from "@mui/icons-material/FolderOpenRounded";
|
||||||
|
import CheckCircleRounded from "@mui/icons-material/CheckCircleRounded";
|
||||||
|
import BlockRounded from "@mui/icons-material/BlockRounded";
|
||||||
|
import PushPinRounded from "@mui/icons-material/PushPinRounded";
|
||||||
|
import KeyboardArrowDownRounded from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||||
|
import KeyboardArrowUpRounded from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||||
|
import type { PermissionReply } from "@/lib/chatStream";
|
||||||
|
|
||||||
type AgentTurnProps = {
|
type AgentTurnProps = {
|
||||||
message: Message;
|
message: Message;
|
||||||
@@ -55,6 +67,7 @@ type AgentTurnProps = {
|
|||||||
onRegenerate: () => void;
|
onRegenerate: () => void;
|
||||||
onEditResubmit: (messageId: string, newContent: string) => void;
|
onEditResubmit: (messageId: string, newContent: string) => void;
|
||||||
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
|
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
|
||||||
|
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MarkdownBlock = ({ children }: { children: string }) => (
|
const MarkdownBlock = ({ children }: { children: string }) => (
|
||||||
@@ -63,6 +76,586 @@ const MarkdownBlock = ({ children }: { children: string }) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const formatMetadataValue = (value: unknown) => {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return "[unserializable]";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const truncateText = (value: string, maxLength: number) =>
|
||||||
|
value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value;
|
||||||
|
|
||||||
|
const formatMetadata = (metadata: Record<string, unknown>) => {
|
||||||
|
const entries = Object.entries(metadata)
|
||||||
|
.filter(([key]) => !["command", "path", "file", "directory"].includes(key))
|
||||||
|
.slice(0, 3);
|
||||||
|
if (!entries.length) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
.map(([key, value]) => `${key}: ${truncateText(formatMetadataValue(value), 64)}`)
|
||||||
|
.join(";");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPermissionTitle = (permission: NonNullable<Message["permissions"]>[number]) => {
|
||||||
|
if (permission.permission === "external_directory") return "访问工作区外目录";
|
||||||
|
if (permission.permission === "bash") return "执行终端命令";
|
||||||
|
if (permission.permission === "edit") return "修改文件内容";
|
||||||
|
return permission.permission || "工具权限请求";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPermissionPrimaryValue = (
|
||||||
|
permission: NonNullable<Message["permissions"]>[number],
|
||||||
|
) => {
|
||||||
|
const command = permission.metadata.command;
|
||||||
|
if (typeof command === "string" && command.trim()) {
|
||||||
|
return command.trim();
|
||||||
|
}
|
||||||
|
for (const key of ["path", "file", "directory"]) {
|
||||||
|
const value = permission.metadata[key];
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return permission.patterns[0] ?? permission.permission;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PermissionIcon = ({
|
||||||
|
permission,
|
||||||
|
}: {
|
||||||
|
permission: NonNullable<Message["permissions"]>[number];
|
||||||
|
}) => {
|
||||||
|
if (permission.permission === "bash") {
|
||||||
|
return <TerminalRounded sx={{ fontSize: 22 }} />;
|
||||||
|
}
|
||||||
|
if (permission.permission === "external_directory") {
|
||||||
|
return <FolderOpenRounded sx={{ fontSize: 22 }} />;
|
||||||
|
}
|
||||||
|
return <VerifiedUserRounded sx={{ fontSize: 22 }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPermissionStatusLabel = (status: NonNullable<Message["permissions"]>[number]["status"]) => {
|
||||||
|
if (status === "approved_always") return "已始终允许";
|
||||||
|
if (status === "approved_once") return "已允许一次";
|
||||||
|
if (status === "rejected") return "已拒绝";
|
||||||
|
if (status === "error") return "提交失败";
|
||||||
|
if (status === "submitting") return "提交中";
|
||||||
|
return "等待确认";
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingPermissionColor = "#f9a825";
|
||||||
|
|
||||||
|
const PermissionRequestCard = ({
|
||||||
|
permission,
|
||||||
|
onReply,
|
||||||
|
}: {
|
||||||
|
permission: NonNullable<Message["permissions"]>[number];
|
||||||
|
onReply: (requestId: string, reply: PermissionReply) => void;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isPending = permission.status === "pending" || permission.status === "error";
|
||||||
|
const isSubmitting = permission.status === "submitting";
|
||||||
|
const primaryValue = getPermissionPrimaryValue(permission);
|
||||||
|
const metadataText = formatMetadata(permission.metadata);
|
||||||
|
const accentColor =
|
||||||
|
permission.status === "rejected" || permission.status === "error"
|
||||||
|
? theme.palette.error.main
|
||||||
|
: permission.status === "pending" || permission.status === "submitting"
|
||||||
|
? pendingPermissionColor
|
||||||
|
: theme.palette.success.main;
|
||||||
|
const statusLabel = getPermissionStatusLabel(permission.status);
|
||||||
|
const statusColor =
|
||||||
|
permission.status === "rejected" || permission.status === "error"
|
||||||
|
? "error"
|
||||||
|
: permission.status === "pending" || permission.status === "submitting"
|
||||||
|
? "warning"
|
||||||
|
: "success";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: "hidden",
|
||||||
|
border: `1px solid ${alpha("#fff", 0.72)}`,
|
||||||
|
bgcolor: alpha("#fff", 0.5),
|
||||||
|
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: accentColor,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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: accentColor,
|
||||||
|
bgcolor: alpha(accentColor, 0.1),
|
||||||
|
border: `1px solid ${alpha(accentColor, 0.16)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PermissionIcon permission={permission} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
|
||||||
|
{getPermissionTitle(permission)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
color={statusColor}
|
||||||
|
label={statusLabel}
|
||||||
|
sx={{
|
||||||
|
height: 24,
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
borderRadius: "12px",
|
||||||
|
bgcolor:
|
||||||
|
statusColor === "success"
|
||||||
|
? alpha(theme.palette.success.main, 0.12)
|
||||||
|
: statusColor === "error"
|
||||||
|
? alpha(theme.palette.error.main, 0.1)
|
||||||
|
: alpha(pendingPermissionColor, 0.14),
|
||||||
|
color:
|
||||||
|
statusColor === "success"
|
||||||
|
? theme.palette.success.dark
|
||||||
|
: statusColor === "error"
|
||||||
|
? theme.palette.error.main
|
||||||
|
: "#8a5a00",
|
||||||
|
"& .MuiChip-label": { px: 1 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack spacing={1.15} sx={{ px: 1.5, pt: 1.25, pb: 1.35, pl: 1.75 }}>
|
||||||
|
<Box
|
||||||
|
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}>
|
||||||
|
请求目标
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.primary"
|
||||||
|
fontFamily={permission.permission === "bash" ? "monospace" : undefined}
|
||||||
|
sx={{
|
||||||
|
mt: 0.25,
|
||||||
|
lineHeight: 1.55,
|
||||||
|
wordBreak: "break-word",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{primaryValue}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{metadataText ? (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ wordBreak: "break-word" }}>
|
||||||
|
{metadataText}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{permission.error ? (
|
||||||
|
<Box sx={{ px: 1.5, pb: isPending || isSubmitting ? 1 : 1.35, pl: 1.75 }}>
|
||||||
|
<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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{permission.error}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isPending || isSubmitting ? (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
flexWrap="wrap"
|
||||||
|
useFlexGap
|
||||||
|
sx={{ px: 1.5, pb: 1.35, pl: 1.75, pt: 0 }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
disableElevation
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={() => onReply(permission.requestId, "once")}
|
||||||
|
startIcon={
|
||||||
|
isSubmitting ? (
|
||||||
|
<CircularProgress size={14} color="inherit" />
|
||||||
|
) : (
|
||||||
|
<CheckCircleRounded fontSize="small" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sx={{
|
||||||
|
minWidth: 94,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: "17px",
|
||||||
|
bgcolor: "#00838f",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
textTransform: "none",
|
||||||
|
boxShadow: `0 4px 12px ${alpha("#00838f", 0.24)}`,
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: "#006c78",
|
||||||
|
boxShadow: `0 6px 16px ${alpha("#00838f", 0.28)}`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
允许一次
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={() => onReply(permission.requestId, "always")}
|
||||||
|
startIcon={<PushPinRounded fontSize="small" />}
|
||||||
|
sx={{
|
||||||
|
height: 34,
|
||||||
|
borderRadius: "17px",
|
||||||
|
px: 1.5,
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
textTransform: "none",
|
||||||
|
color: "#00838f",
|
||||||
|
borderColor: alpha("#00838f", 0.24),
|
||||||
|
bgcolor: alpha("#fff", 0.45),
|
||||||
|
"&:hover": {
|
||||||
|
borderColor: alpha("#00838f", 0.36),
|
||||||
|
bgcolor: alpha("#00838f", 0.08),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
始终允许
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
variant="outlined"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={() => onReply(permission.requestId, "reject")}
|
||||||
|
startIcon={<BlockRounded fontSize="small" />}
|
||||||
|
sx={{
|
||||||
|
height: 34,
|
||||||
|
borderRadius: "17px",
|
||||||
|
px: 1.5,
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
textTransform: "none",
|
||||||
|
borderColor: alpha(theme.palette.error.main, 0.22),
|
||||||
|
bgcolor: alpha("#fff", 0.45),
|
||||||
|
"&:hover": {
|
||||||
|
borderColor: alpha(theme.palette.error.main, 0.34),
|
||||||
|
bgcolor: alpha(theme.palette.error.main, 0.07),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
拒绝
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PermissionRequestGroup = ({
|
||||||
|
permissions,
|
||||||
|
onReply,
|
||||||
|
}: {
|
||||||
|
permissions: NonNullable<Message["permissions"]>;
|
||||||
|
onReply: (requestId: string, reply: PermissionReply) => void;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const onceCount = permissions.filter((permission) => permission.status === "approved_once").length;
|
||||||
|
const alwaysCount = permissions.filter((permission) => permission.status === "approved_always").length;
|
||||||
|
const rejectedCount = permissions.filter((permission) => permission.status === "rejected").length;
|
||||||
|
const pendingCount = permissions.length - onceCount - alwaysCount - rejectedCount;
|
||||||
|
const hasPendingPermissions = pendingCount > 0;
|
||||||
|
const [expanded, setExpanded] = React.useState(false);
|
||||||
|
const latestPermissions = permissions.slice(-3);
|
||||||
|
const pendingPermissions = permissions.filter(
|
||||||
|
(permission) =>
|
||||||
|
permission.status === "pending" ||
|
||||||
|
permission.status === "submitting" ||
|
||||||
|
permission.status === "error",
|
||||||
|
);
|
||||||
|
const summaryItems = [
|
||||||
|
{ label: "共", value: permissions.length, color: theme.palette.text.secondary },
|
||||||
|
{ label: "允许一次", value: onceCount, color: "#00838f" },
|
||||||
|
{ label: "始终允许", value: alwaysCount, color: theme.palette.success.main },
|
||||||
|
{ label: "拒绝", value: rejectedCount, color: theme.palette.error.main },
|
||||||
|
];
|
||||||
|
const chipColor = pendingCount > 0 ? pendingPermissionColor : rejectedCount > 0 ? theme.palette.error.main : theme.palette.success.main;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
borderRadius: 3,
|
||||||
|
overflow: "hidden",
|
||||||
|
border: `1px solid ${alpha("#fff", 0.72)}`,
|
||||||
|
bgcolor: alpha("#fff", 0.46),
|
||||||
|
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: chipColor,
|
||||||
|
bgcolor: alpha(chipColor, 0.1),
|
||||||
|
border: `1px solid ${alpha(chipColor, 0.15)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VerifiedUserRounded sx={{ fontSize: 18 }} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
|
||||||
|
权限请求
|
||||||
|
</Typography>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
flexWrap="wrap"
|
||||||
|
gap={0.6}
|
||||||
|
sx={{ mt: 0.55, maxHeight: 48, overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
{summaryItems.map((item) => (
|
||||||
|
<Box
|
||||||
|
key={item.label}
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.45,
|
||||||
|
height: 22,
|
||||||
|
px: 0.8,
|
||||||
|
borderRadius: "11px",
|
||||||
|
bgcolor: alpha(item.color, 0.08),
|
||||||
|
border: `1px solid ${alpha(item.color, 0.12)}`,
|
||||||
|
color: item.color,
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
lineHeight: 1,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box component="span" sx={{ color: alpha(item.color, 0.82), fontWeight: 700 }}>
|
||||||
|
{item.label}
|
||||||
|
</Box>
|
||||||
|
<Box component="span">{item.value} 项</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={`待确认 ${pendingCount} 项`}
|
||||||
|
sx={{
|
||||||
|
height: 24,
|
||||||
|
borderRadius: "12px",
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
color: chipColor,
|
||||||
|
bgcolor: alpha(chipColor, 0.1),
|
||||||
|
"& .MuiChip-label": { px: 1 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{!expanded && !hasPendingPermissions && latestPermissions.length > 0 ? (
|
||||||
|
<Stack spacing={0} sx={{ px: 1.5, pb: 1.25 }}>
|
||||||
|
{latestPermissions.map((permission, index) => {
|
||||||
|
const primaryValue = getPermissionPrimaryValue(permission);
|
||||||
|
const isLast = index === latestPermissions.length - 1;
|
||||||
|
const itemColor =
|
||||||
|
permission.status === "rejected" || permission.status === "error"
|
||||||
|
? theme.palette.error.main
|
||||||
|
: permission.status === "approved_once" || permission.status === "approved_always"
|
||||||
|
? theme.palette.success.main
|
||||||
|
: pendingPermissionColor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
key={permission.requestId}
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
alignItems="center"
|
||||||
|
sx={{
|
||||||
|
py: 0.8,
|
||||||
|
borderTop: index === 0 ? `1px solid ${alpha(chipColor, 0.1)}` : "none",
|
||||||
|
borderBottom: isLast ? "none" : `1px solid ${alpha("#000", 0.045)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
color: itemColor,
|
||||||
|
bgcolor: alpha(itemColor, 0.08),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PermissionIcon permission={permission} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography variant="caption" color="text.primary" fontWeight={750} noWrap sx={{ display: "block" }}>
|
||||||
|
{getPermissionTitle(permission)}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
noWrap
|
||||||
|
sx={{
|
||||||
|
display: "block",
|
||||||
|
fontFamily: permission.permission === "bash" ? "monospace" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{truncateText(primaryValue, 72)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={getPermissionStatusLabel(permission.status)}
|
||||||
|
sx={{
|
||||||
|
height: 22,
|
||||||
|
borderRadius: "11px",
|
||||||
|
fontSize: "0.68rem",
|
||||||
|
fontWeight: 800,
|
||||||
|
color: itemColor,
|
||||||
|
bgcolor: alpha(itemColor, 0.08),
|
||||||
|
"& .MuiChip-label": { px: 0.85 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{!expanded && hasPendingPermissions ? (
|
||||||
|
<motion.div
|
||||||
|
key="pending-permissions"
|
||||||
|
initial={{ opacity: 0, y: -10, height: 0 }}
|
||||||
|
animate={{ opacity: 1, y: 0, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, y: -8, height: 0 }}
|
||||||
|
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||||
|
style={{ overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<Stack spacing={1} sx={{ px: 1.25, pb: 1.25 }}>
|
||||||
|
{pendingPermissions.map((permission) => (
|
||||||
|
<PermissionRequestCard
|
||||||
|
key={permission.requestId}
|
||||||
|
permission={permission}
|
||||||
|
onReply={onReply}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<Collapse in={expanded} timeout="auto" unmountOnExit>
|
||||||
|
<Stack spacing={1} sx={{ px: 1.25, pb: 1.25 }}>
|
||||||
|
{permissions.map((permission) => (
|
||||||
|
<PermissionRequestCard
|
||||||
|
key={permission.requestId}
|
||||||
|
permission={permission}
|
||||||
|
onReply={onReply}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const AgentTurn = React.memo(
|
export const AgentTurn = React.memo(
|
||||||
({
|
({
|
||||||
message,
|
message,
|
||||||
@@ -76,6 +669,7 @@ export const AgentTurn = React.memo(
|
|||||||
onRegenerate,
|
onRegenerate,
|
||||||
onEditResubmit,
|
onEditResubmit,
|
||||||
onCycleBranch,
|
onCycleBranch,
|
||||||
|
onReplyPermission,
|
||||||
}: AgentTurnProps) => {
|
}: AgentTurnProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isUser = message.role === "user";
|
const isUser = message.role === "user";
|
||||||
@@ -359,6 +953,13 @@ export const AgentTurn = React.memo(
|
|||||||
<AgentProgressTimeline progress={message.progress} isAborted={isErrorMessage} />
|
<AgentProgressTimeline progress={message.progress} isAborted={isErrorMessage} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{message.permissions?.length ? (
|
||||||
|
<PermissionRequestGroup
|
||||||
|
permissions={message.permissions}
|
||||||
|
onReply={onReplyPermission}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
p: 1.5,
|
p: 1.5,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ describe("AgentWorkspace", () => {
|
|||||||
onRegenerate: jest.fn(),
|
onRegenerate: jest.fn(),
|
||||||
onEditResubmit: jest.fn(),
|
onEditResubmit: jest.fn(),
|
||||||
onCycleBranch: jest.fn(),
|
onCycleBranch: jest.fn(),
|
||||||
|
onReplyPermission: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import MapRounded from "@mui/icons-material/MapRounded";
|
|||||||
|
|
||||||
import { AgentTurn } from "./AgentTurn";
|
import { AgentTurn } from "./AgentTurn";
|
||||||
import { TypingIndicator } from "./GlobalChatbox.parts";
|
import { TypingIndicator } from "./GlobalChatbox.parts";
|
||||||
|
import type { PermissionReply } from "@/lib/chatStream";
|
||||||
import type {
|
import type {
|
||||||
BranchGroup,
|
BranchGroup,
|
||||||
BranchState,
|
BranchState,
|
||||||
@@ -35,6 +36,7 @@ type AgentWorkspaceProps = {
|
|||||||
onRegenerate: () => void;
|
onRegenerate: () => void;
|
||||||
onEditResubmit: (messageId: string, newContent: string) => void;
|
onEditResubmit: (messageId: string, newContent: string) => void;
|
||||||
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
|
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
|
||||||
|
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TurnListProps = {
|
type TurnListProps = {
|
||||||
@@ -50,6 +52,7 @@ type TurnListProps = {
|
|||||||
onRegenerate: () => void;
|
onRegenerate: () => void;
|
||||||
onEditResubmit: (messageId: string, newContent: string) => void;
|
onEditResubmit: (messageId: string, newContent: string) => void;
|
||||||
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
|
onCycleBranch: (rootMessageId: string, direction: -1 | 1) => void;
|
||||||
|
onReplyPermission: (requestId: string, reply: PermissionReply) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sameMessages = (left: Message[], right: Message[]) =>
|
const sameMessages = (left: Message[], right: Message[]) =>
|
||||||
@@ -69,6 +72,7 @@ const TurnListInner = ({
|
|||||||
onRegenerate,
|
onRegenerate,
|
||||||
onEditResubmit,
|
onEditResubmit,
|
||||||
onCycleBranch,
|
onCycleBranch,
|
||||||
|
onReplyPermission,
|
||||||
}: TurnListProps) => {
|
}: TurnListProps) => {
|
||||||
const branchStateByRootId = React.useMemo(() => {
|
const branchStateByRootId = React.useMemo(() => {
|
||||||
const next = new Map<string, BranchState>();
|
const next = new Map<string, BranchState>();
|
||||||
@@ -101,6 +105,7 @@ const TurnListInner = ({
|
|||||||
onRegenerate={onRegenerate}
|
onRegenerate={onRegenerate}
|
||||||
onEditResubmit={onEditResubmit}
|
onEditResubmit={onEditResubmit}
|
||||||
onCycleBranch={onCycleBranch}
|
onCycleBranch={onCycleBranch}
|
||||||
|
onReplyPermission={onReplyPermission}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -122,7 +127,8 @@ const TurnList = React.memo(
|
|||||||
prevProps.isTtsSupported === nextProps.isTtsSupported &&
|
prevProps.isTtsSupported === nextProps.isTtsSupported &&
|
||||||
prevProps.onRegenerate === nextProps.onRegenerate &&
|
prevProps.onRegenerate === nextProps.onRegenerate &&
|
||||||
prevProps.onEditResubmit === nextProps.onEditResubmit &&
|
prevProps.onEditResubmit === nextProps.onEditResubmit &&
|
||||||
prevProps.onCycleBranch === nextProps.onCycleBranch,
|
prevProps.onCycleBranch === nextProps.onCycleBranch &&
|
||||||
|
prevProps.onReplyPermission === nextProps.onReplyPermission,
|
||||||
);
|
);
|
||||||
|
|
||||||
TurnList.displayName = "TurnList";
|
TurnList.displayName = "TurnList";
|
||||||
@@ -257,6 +263,7 @@ export const AgentWorkspace = ({
|
|||||||
onRegenerate,
|
onRegenerate,
|
||||||
onEditResubmit,
|
onEditResubmit,
|
||||||
onCycleBranch,
|
onCycleBranch,
|
||||||
|
onReplyPermission,
|
||||||
}: AgentWorkspaceProps) => {
|
}: AgentWorkspaceProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const latestAssistant = [...messages]
|
const latestAssistant = [...messages]
|
||||||
@@ -311,6 +318,7 @@ export const AgentWorkspace = ({
|
|||||||
onRegenerate={onRegenerate}
|
onRegenerate={onRegenerate}
|
||||||
onEditResubmit={onEditResubmit}
|
onEditResubmit={onEditResubmit}
|
||||||
onCycleBranch={onCycleBranch}
|
onCycleBranch={onCycleBranch}
|
||||||
|
onReplyPermission={onReplyPermission}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{streamingMessage ? (
|
{streamingMessage ? (
|
||||||
@@ -327,6 +335,7 @@ export const AgentWorkspace = ({
|
|||||||
onRegenerate={onRegenerate}
|
onRegenerate={onRegenerate}
|
||||||
onEditResubmit={onEditResubmit}
|
onEditResubmit={onEditResubmit}
|
||||||
onCycleBranch={onCycleBranch}
|
onCycleBranch={onCycleBranch}
|
||||||
|
onReplyPermission={onReplyPermission}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -353,6 +362,7 @@ export const AgentWorkspace = ({
|
|||||||
onRegenerate={onRegenerate}
|
onRegenerate={onRegenerate}
|
||||||
onEditResubmit={onEditResubmit}
|
onEditResubmit={onEditResubmit}
|
||||||
onCycleBranch={onCycleBranch}
|
onCycleBranch={onCycleBranch}
|
||||||
|
onReplyPermission={onReplyPermission}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
editAndResubmit,
|
editAndResubmit,
|
||||||
cycleBranch,
|
cycleBranch,
|
||||||
abort,
|
abort,
|
||||||
|
replyPermission,
|
||||||
createSession,
|
createSession,
|
||||||
renameSession,
|
renameSession,
|
||||||
removeSession,
|
removeSession,
|
||||||
@@ -354,6 +355,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
onRegenerate={regenerate}
|
onRegenerate={regenerate}
|
||||||
onEditResubmit={editAndResubmit}
|
onEditResubmit={editAndResubmit}
|
||||||
onCycleBranch={cycleBranch}
|
onCycleBranch={cycleBranch}
|
||||||
|
onReplyPermission={replyPermission}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AgentComposer
|
<AgentComposer
|
||||||
|
|||||||
@@ -22,6 +22,31 @@ export type AgentArtifact = {
|
|||||||
params: Record<string, unknown>;
|
params: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AgentPermissionStatus =
|
||||||
|
| "pending"
|
||||||
|
| "submitting"
|
||||||
|
| "approved_once"
|
||||||
|
| "approved_always"
|
||||||
|
| "rejected"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
export type AgentPermissionRequest = {
|
||||||
|
requestId: string;
|
||||||
|
sessionId: string;
|
||||||
|
permission: string;
|
||||||
|
patterns: string[];
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
always: string[];
|
||||||
|
tool?: {
|
||||||
|
messageID: string;
|
||||||
|
callID: string;
|
||||||
|
};
|
||||||
|
createdAt: number;
|
||||||
|
repliedAt?: number;
|
||||||
|
status: AgentPermissionStatus;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type Message = {
|
export type Message = {
|
||||||
id: string;
|
id: string;
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
@@ -29,6 +54,7 @@ export type Message = {
|
|||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
progress?: ChatProgress[];
|
progress?: ChatProgress[];
|
||||||
artifacts?: AgentArtifact[];
|
artifacts?: AgentArtifact[];
|
||||||
|
permissions?: AgentPermissionRequest[];
|
||||||
branchRootId?: string;
|
branchRootId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,18 @@
|
|||||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
import { useAgentChatSession } from "./useAgentChatSession";
|
import { useAgentChatSession } from "./useAgentChatSession";
|
||||||
import { abortAgentChat, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream";
|
import {
|
||||||
|
abortAgentChat,
|
||||||
|
replyAgentPermission,
|
||||||
|
resumeAgentChatStream,
|
||||||
|
streamAgentChat,
|
||||||
|
} from "@/lib/chatStream";
|
||||||
import type { StreamEvent } from "@/lib/chatStream";
|
import type { StreamEvent } from "@/lib/chatStream";
|
||||||
|
|
||||||
jest.mock("@/lib/chatStream", () => ({
|
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),
|
||||||
resumeAgentChatStream: jest.fn(async () => undefined),
|
resumeAgentChatStream: jest.fn(async () => undefined),
|
||||||
streamAgentChat: jest.fn(async () => undefined),
|
streamAgentChat: jest.fn(async () => undefined),
|
||||||
}));
|
}));
|
||||||
@@ -46,9 +52,11 @@ describe("useAgentChatSession", () => {
|
|||||||
saveActiveChatState.mockReset();
|
saveActiveChatState.mockReset();
|
||||||
updateChatSessionTitle.mockReset();
|
updateChatSessionTitle.mockReset();
|
||||||
jest.mocked(abortAgentChat).mockReset();
|
jest.mocked(abortAgentChat).mockReset();
|
||||||
|
jest.mocked(replyAgentPermission).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(replyAgentPermission).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);
|
||||||
@@ -353,6 +361,62 @@ describe("useAgentChatSession", () => {
|
|||||||
expect(abortAgentChat).toHaveBeenCalledWith("session-loaded");
|
expect(abortAgentChat).toHaveBeenCalledWith("session-loaded");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("tracks permission requests and submits replies", async () => {
|
||||||
|
listChatSessions.mockResolvedValue([]);
|
||||||
|
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
|
||||||
|
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
|
emitStreamEvent = onEvent;
|
||||||
|
await new Promise<void>(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAgentChatSession({
|
||||||
|
projectId: "project-1",
|
||||||
|
onToolCall: jest.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isHydrating).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
void result.current.sendPrompt("删除临时文件");
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
emitStreamEvent?.({
|
||||||
|
type: "permission_request",
|
||||||
|
sessionId: "session-1",
|
||||||
|
requestId: "perm-1",
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["rm *"],
|
||||||
|
metadata: { command: "rm tmp.txt" },
|
||||||
|
always: ["rm *"],
|
||||||
|
createdAt: 123,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.messages.at(-1)?.permissions).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
requestId: "perm-1",
|
||||||
|
sessionId: "session-1",
|
||||||
|
status: "pending",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.replyPermission("perm-1", "once");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replyAgentPermission).toHaveBeenCalledWith("session-1", "perm-1", "once");
|
||||||
|
expect(result.current.messages.at(-1)?.permissions?.[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
requestId: "perm-1",
|
||||||
|
status: "approved_once",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("finalizes running progress when aborting an active prompt", async () => {
|
it("finalizes running progress when aborting an active prompt", async () => {
|
||||||
listChatSessions.mockResolvedValue([]);
|
listChatSessions.mockResolvedValue([]);
|
||||||
jest.mocked(streamAgentChat).mockImplementationOnce(
|
jest.mocked(streamAgentChat).mockImplementationOnce(
|
||||||
@@ -368,7 +432,7 @@ describe("useAgentChatSession", () => {
|
|||||||
startedAt: 1000,
|
startedAt: 1000,
|
||||||
} satisfies StreamEvent);
|
} satisfies StreamEvent);
|
||||||
|
|
||||||
signal.addEventListener("abort", () => {
|
signal?.addEventListener("abort", () => {
|
||||||
reject(new Error("aborted"));
|
reject(new Error("aborted"));
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
abortAgentChat,
|
abortAgentChat,
|
||||||
forkAgentChat,
|
forkAgentChat,
|
||||||
|
replyAgentPermission,
|
||||||
resumeAgentChatStream,
|
resumeAgentChatStream,
|
||||||
streamAgentChat,
|
streamAgentChat,
|
||||||
} from "@/lib/chatStream";
|
} from "@/lib/chatStream";
|
||||||
import type { AgentModel, StreamEvent } from "@/lib/chatStream";
|
import type { AgentModel, PermissionReply, StreamEvent } from "@/lib/chatStream";
|
||||||
import type {
|
import type {
|
||||||
AgentArtifact,
|
AgentArtifact,
|
||||||
|
AgentPermissionRequest,
|
||||||
BranchGroup,
|
BranchGroup,
|
||||||
BranchTransition,
|
BranchTransition,
|
||||||
ChatProgress,
|
ChatProgress,
|
||||||
@@ -130,6 +132,41 @@ const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const upsertPermission = (
|
||||||
|
permissions: AgentPermissionRequest[] | undefined,
|
||||||
|
event: StreamEvent & { type: "permission_request" },
|
||||||
|
) => {
|
||||||
|
const next = [...(permissions ?? [])];
|
||||||
|
const index = next.findIndex((item) => item.requestId === event.requestId);
|
||||||
|
const nextItem: AgentPermissionRequest = {
|
||||||
|
requestId: event.requestId,
|
||||||
|
sessionId: event.sessionId,
|
||||||
|
permission: event.permission,
|
||||||
|
patterns: event.patterns,
|
||||||
|
metadata: event.metadata,
|
||||||
|
always: event.always,
|
||||||
|
tool: event.tool,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
status: "pending",
|
||||||
|
};
|
||||||
|
if (index >= 0) {
|
||||||
|
next[index] = {
|
||||||
|
...next[index],
|
||||||
|
...nextItem,
|
||||||
|
status: next[index].status === "submitting" ? "submitting" : nextItem.status,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
next.push(nextItem);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toPermissionStatus = (reply: PermissionReply): AgentPermissionRequest["status"] => {
|
||||||
|
if (reply === "always") return "approved_always";
|
||||||
|
if (reply === "once") return "approved_once";
|
||||||
|
return "rejected";
|
||||||
|
};
|
||||||
|
|
||||||
const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
|
const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
|
||||||
const completedProgress = completeRunningProgress(message.progress);
|
const completedProgress = completeRunningProgress(message.progress);
|
||||||
const hasVisibleOutput =
|
const hasVisibleOutput =
|
||||||
@@ -442,6 +479,38 @@ export const useAgentChatSession = ({
|
|||||||
assistantMessageId,
|
assistantMessageId,
|
||||||
appendArtifact,
|
appendArtifact,
|
||||||
});
|
});
|
||||||
|
} else if (event.type === "permission_request") {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
message.id === assistantMessageId
|
||||||
|
? {
|
||||||
|
...message,
|
||||||
|
permissions: upsertPermission(message.permissions, event),
|
||||||
|
}
|
||||||
|
: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (event.type === "permission_response") {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) => {
|
||||||
|
if (message.id !== assistantMessageId || !message.permissions?.length) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
permissions: message.permissions.map((permission) =>
|
||||||
|
permission.requestId === event.requestId
|
||||||
|
? {
|
||||||
|
...permission,
|
||||||
|
status: toPermissionStatus(event.reply),
|
||||||
|
repliedAt: Date.now(),
|
||||||
|
error: undefined,
|
||||||
|
}
|
||||||
|
: permission,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
} else if (event.type === "session_title") {
|
} else if (event.type === "session_title") {
|
||||||
const nextTitle = event.title.trim();
|
const nextTitle = event.title.trim();
|
||||||
if (nextTitle && !isSessionTitleManuallyEditedRef.current) {
|
if (nextTitle && !isSessionTitleManuallyEditedRef.current) {
|
||||||
@@ -647,6 +716,75 @@ export const useAgentChatSession = ({
|
|||||||
cancelPromiseRef.current = trackedCancelPromise;
|
cancelPromiseRef.current = trackedCancelPromise;
|
||||||
}, [getLastAssistantMessageId]);
|
}, [getLastAssistantMessageId]);
|
||||||
|
|
||||||
|
const replyPermission = useCallback(
|
||||||
|
async (requestId: string, reply: PermissionReply) => {
|
||||||
|
const target = messagesRef.current
|
||||||
|
.flatMap((message) => message.permissions ?? [])
|
||||||
|
.find((permission) => permission.requestId === requestId);
|
||||||
|
if (!target || target.status === "submitting") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
!message.permissions?.some((permission) => permission.requestId === requestId)
|
||||||
|
? message
|
||||||
|
: {
|
||||||
|
...message,
|
||||||
|
permissions: message.permissions.map((permission) =>
|
||||||
|
permission.requestId === requestId
|
||||||
|
? { ...permission, status: "submitting", error: undefined }
|
||||||
|
: permission,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await replyAgentPermission(target.sessionId, requestId, reply);
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
!message.permissions?.some((permission) => permission.requestId === requestId)
|
||||||
|
? message
|
||||||
|
: {
|
||||||
|
...message,
|
||||||
|
permissions: message.permissions.map((permission) =>
|
||||||
|
permission.requestId === requestId
|
||||||
|
? {
|
||||||
|
...permission,
|
||||||
|
status: toPermissionStatus(reply),
|
||||||
|
repliedAt: Date.now(),
|
||||||
|
error: undefined,
|
||||||
|
}
|
||||||
|
: permission,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((message) =>
|
||||||
|
!message.permissions?.some((permission) => permission.requestId === requestId)
|
||||||
|
? message
|
||||||
|
: {
|
||||||
|
...message,
|
||||||
|
permissions: message.permissions.map((permission) =>
|
||||||
|
permission.requestId === requestId
|
||||||
|
? {
|
||||||
|
...permission,
|
||||||
|
status: "error",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
}
|
||||||
|
: permission,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const createSession = useCallback(() => {
|
const createSession = useCallback(() => {
|
||||||
if (isHydrating || isStreaming) return;
|
if (isHydrating || isStreaming) return;
|
||||||
|
|
||||||
@@ -982,6 +1120,7 @@ export const useAgentChatSession = ({
|
|||||||
editAndResubmit,
|
editAndResubmit,
|
||||||
cycleBranch,
|
cycleBranch,
|
||||||
abort,
|
abort,
|
||||||
|
replyPermission,
|
||||||
createSession,
|
createSession,
|
||||||
renameSession,
|
renameSession,
|
||||||
removeSession,
|
removeSession,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
abortAgentChat,
|
abortAgentChat,
|
||||||
forkAgentChat,
|
forkAgentChat,
|
||||||
|
replyAgentPermission,
|
||||||
|
type StreamEvent,
|
||||||
resumeAgentChatStream,
|
resumeAgentChatStream,
|
||||||
streamAgentChat,
|
streamAgentChat,
|
||||||
} from "./chatStream";
|
} from "./chatStream";
|
||||||
@@ -162,12 +164,7 @@ describe("streamAgentChat", () => {
|
|||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const events: Array<{
|
const events: StreamEvent[] = [];
|
||||||
type: string;
|
|
||||||
sessionId?: string;
|
|
||||||
tool?: string;
|
|
||||||
params?: Record<string, unknown>;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
await streamAgentChat({
|
await streamAgentChat({
|
||||||
message: "hi",
|
message: "hi",
|
||||||
@@ -182,6 +179,43 @@ describe("streamAgentChat", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses permission request and response events", async () => {
|
||||||
|
apiFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
body: makeStream([
|
||||||
|
'event: permission_request\ndata: {"session_id":"s1","request_id":"perm-1","permission":"bash","patterns":["rm *"],"metadata":{"command":"rm tmp.txt"},"always":["rm *"],"created_at":123}\n\n',
|
||||||
|
'event: permission_response\ndata: {"session_id":"s1","request_id":"perm-1","reply":"reject"}\n\n',
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const events: StreamEvent[] = [];
|
||||||
|
|
||||||
|
await streamAgentChat({
|
||||||
|
message: "hi",
|
||||||
|
onEvent: (event) => events.push(event),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "permission_request",
|
||||||
|
sessionId: "s1",
|
||||||
|
requestId: "perm-1",
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["rm *"],
|
||||||
|
metadata: { command: "rm tmp.txt" },
|
||||||
|
always: ["rm *"],
|
||||||
|
tool: undefined,
|
||||||
|
createdAt: 123,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "permission_response",
|
||||||
|
sessionId: "s1",
|
||||||
|
requestId: "perm-1",
|
||||||
|
reply: "reject",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
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,
|
||||||
@@ -255,6 +289,29 @@ describe("streamAgentChat", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("calls permission reply endpoint", async () => {
|
||||||
|
apiFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 202,
|
||||||
|
text: async () => "",
|
||||||
|
});
|
||||||
|
|
||||||
|
await replyAgentPermission("s1", "perm-1", "once");
|
||||||
|
|
||||||
|
expect(apiFetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("/api/v1/agent/chat/permission/perm-1/reply"),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
projectHeaderMode: "include",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: "s1",
|
||||||
|
reply: "once",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
+89
-2
@@ -5,6 +5,8 @@ export type AgentModel =
|
|||||||
| "deepseek/deepseek-v4-flash"
|
| "deepseek/deepseek-v4-flash"
|
||||||
| "deepseek/deepseek-v4-pro";
|
| "deepseek/deepseek-v4-pro";
|
||||||
|
|
||||||
|
export type PermissionReply = "once" | "always" | "reject";
|
||||||
|
|
||||||
export type StreamEvent =
|
export type StreamEvent =
|
||||||
| {
|
| {
|
||||||
type: "state";
|
type: "state";
|
||||||
@@ -41,6 +43,26 @@ export type StreamEvent =
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
tool: string;
|
tool: string;
|
||||||
params: Record<string, unknown>;
|
params: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "permission_request";
|
||||||
|
sessionId: string;
|
||||||
|
requestId: string;
|
||||||
|
permission: string;
|
||||||
|
patterns: string[];
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
always: string[];
|
||||||
|
tool?: {
|
||||||
|
messageID: string;
|
||||||
|
callID: string;
|
||||||
|
};
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "permission_response";
|
||||||
|
sessionId: string;
|
||||||
|
requestId: string;
|
||||||
|
reply: PermissionReply;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StreamOptions = {
|
type StreamOptions = {
|
||||||
@@ -111,7 +133,7 @@ const emitParsedStreamEvent = (
|
|||||||
content?: string;
|
content?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
tool?: string;
|
tool?: unknown;
|
||||||
params?: Record<string, unknown>;
|
params?: Record<string, unknown>;
|
||||||
arguments?: unknown;
|
arguments?: unknown;
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -126,6 +148,13 @@ const emitParsedStreamEvent = (
|
|||||||
elapsed_ms?: number;
|
elapsed_ms?: number;
|
||||||
duration_ms?: number;
|
duration_ms?: number;
|
||||||
total_duration_ms?: number;
|
total_duration_ms?: number;
|
||||||
|
request_id?: string;
|
||||||
|
permission?: string;
|
||||||
|
patterns?: unknown;
|
||||||
|
metadata?: unknown;
|
||||||
|
always?: unknown;
|
||||||
|
created_at?: number;
|
||||||
|
reply?: PermissionReply;
|
||||||
};
|
};
|
||||||
if (event === "state") {
|
if (event === "state") {
|
||||||
onEvent({
|
onEvent({
|
||||||
@@ -179,9 +208,39 @@ const emitParsedStreamEvent = (
|
|||||||
onEvent({
|
onEvent({
|
||||||
type: "tool_call",
|
type: "tool_call",
|
||||||
sessionId: parsed.session_id ?? "",
|
sessionId: parsed.session_id ?? "",
|
||||||
tool: parsed.tool ?? "",
|
tool: typeof parsed.tool === "string" ? parsed.tool : "",
|
||||||
params: resolveToolParams(parsed.params, parsed.arguments),
|
params: resolveToolParams(parsed.params, parsed.arguments),
|
||||||
});
|
});
|
||||||
|
} else if (event === "permission_request") {
|
||||||
|
onEvent({
|
||||||
|
type: "permission_request",
|
||||||
|
sessionId: parsed.session_id ?? "",
|
||||||
|
requestId: parsed.request_id ?? "",
|
||||||
|
permission: parsed.permission ?? "",
|
||||||
|
patterns: Array.isArray(parsed.patterns)
|
||||||
|
? parsed.patterns.filter((item): item is string => typeof item === "string")
|
||||||
|
: [],
|
||||||
|
metadata: isObjectRecord(parsed.metadata) ? parsed.metadata : {},
|
||||||
|
always: Array.isArray(parsed.always)
|
||||||
|
? parsed.always.filter((item): item is string => typeof item === "string")
|
||||||
|
: [],
|
||||||
|
tool: isObjectRecord(parsed.tool) &&
|
||||||
|
typeof parsed.tool.messageID === "string" &&
|
||||||
|
typeof parsed.tool.callID === "string"
|
||||||
|
? {
|
||||||
|
messageID: parsed.tool.messageID,
|
||||||
|
callID: parsed.tool.callID,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
createdAt: parsed.created_at ?? Date.now(),
|
||||||
|
});
|
||||||
|
} else if (event === "permission_response") {
|
||||||
|
onEvent({
|
||||||
|
type: "permission_response",
|
||||||
|
sessionId: parsed.session_id ?? "",
|
||||||
|
requestId: parsed.request_id ?? "",
|
||||||
|
reply: parsed.reply ?? "reject",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
onEvent({
|
onEvent({
|
||||||
@@ -349,6 +408,34 @@ export const abortAgentChat = async (sessionId?: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const replyAgentPermission = async (
|
||||||
|
sessionId: string,
|
||||||
|
requestId: string,
|
||||||
|
reply: PermissionReply,
|
||||||
|
) => {
|
||||||
|
const response = await apiFetch(
|
||||||
|
`${config.AGENT_URL}/api/v1/agent/chat/permission/${encodeURIComponent(requestId)}/reply`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: sessionId,
|
||||||
|
reply,
|
||||||
|
}),
|
||||||
|
projectHeaderMode: "include",
|
||||||
|
userHeaderMode: "include",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.text();
|
||||||
|
throw new Error(detail || `permission reply 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