645 lines
22 KiB
TypeScript
645 lines
22 KiB
TypeScript
"use client";
|
||
|
||
import React from "react";
|
||
import { AnimatePresence, motion } from "framer-motion";
|
||
import {
|
||
Box,
|
||
Button,
|
||
Chip,
|
||
CircularProgress,
|
||
Collapse,
|
||
IconButton,
|
||
Stack,
|
||
Typography,
|
||
alpha,
|
||
useTheme,
|
||
} from "@mui/material";
|
||
import type { Theme } from "@mui/material/styles";
|
||
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 VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
|
||
|
||
import type { PermissionReply } from "@/lib/chatStream";
|
||
import type { Message } from "./GlobalChatbox.types";
|
||
|
||
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 === "aborted") return "已中断";
|
||
if (status === "error") return "提交失败";
|
||
if (status === "submitting") return "提交中";
|
||
return "等待确认";
|
||
};
|
||
|
||
const pendingPermissionColor = "#f9a825";
|
||
const approvedOncePermissionColor = "#00838f";
|
||
|
||
const getPermissionStatusColor = (
|
||
status: NonNullable<Message["permissions"]>[number]["status"],
|
||
theme: Theme,
|
||
) => {
|
||
if (status === "approved_once") return approvedOncePermissionColor;
|
||
if (status === "approved_always") return theme.palette.success.main;
|
||
if (status === "rejected" || status === "error") return theme.palette.error.main;
|
||
if (status === "aborted") return theme.palette.text.secondary;
|
||
return pendingPermissionColor;
|
||
};
|
||
|
||
const getPermissionStatusTextColor = (
|
||
status: NonNullable<Message["permissions"]>[number]["status"],
|
||
theme: Theme,
|
||
) => {
|
||
if (status === "approved_once") return "#006c78";
|
||
if (status === "approved_always") return theme.palette.success.dark;
|
||
if (status === "rejected" || status === "error") return theme.palette.error.main;
|
||
if (status === "aborted") return theme.palette.text.secondary;
|
||
return "#8a5a00";
|
||
};
|
||
|
||
const PermissionRequestCard = ({
|
||
permission,
|
||
isRunning,
|
||
onReply,
|
||
}: {
|
||
permission: NonNullable<Message["permissions"]>[number];
|
||
isRunning: boolean;
|
||
onReply: (requestId: string, reply: PermissionReply) => void;
|
||
}) => {
|
||
const theme = useTheme();
|
||
const isPending =
|
||
isRunning && (permission.status === "pending" || permission.status === "error");
|
||
const isSubmitting = isRunning && permission.status === "submitting";
|
||
const primaryValue = getPermissionPrimaryValue(permission);
|
||
const metadataText = formatMetadata(permission.metadata);
|
||
const accentColor = getPermissionStatusColor(permission.status, theme);
|
||
const statusTextColor = getPermissionStatusTextColor(permission.status, theme);
|
||
const statusLabel = getPermissionStatusLabel(permission.status);
|
||
|
||
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"
|
||
label={statusLabel}
|
||
sx={{
|
||
height: 24,
|
||
fontSize: "0.7rem",
|
||
fontWeight: 800,
|
||
borderRadius: "12px",
|
||
bgcolor: alpha(accentColor, 0.12),
|
||
color: statusTextColor,
|
||
"& .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>
|
||
);
|
||
};
|
||
|
||
export const PermissionRequestGroup = ({
|
||
permissions,
|
||
isRunning,
|
||
onReply,
|
||
}: {
|
||
permissions: NonNullable<Message["permissions"]>;
|
||
isRunning: boolean;
|
||
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 abortedCount = permissions.filter((permission) => permission.status === "aborted").length;
|
||
const pendingCount = permissions.filter(
|
||
(permission) =>
|
||
permission.status === "pending" ||
|
||
permission.status === "submitting" ||
|
||
permission.status === "error",
|
||
).length;
|
||
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: getPermissionStatusColor("approved_once", theme), textColor: getPermissionStatusTextColor("approved_once", theme) },
|
||
{ label: "始终允许", value: alwaysCount, color: getPermissionStatusColor("approved_always", theme), textColor: getPermissionStatusTextColor("approved_always", theme) },
|
||
{ label: "拒绝", value: rejectedCount, color: getPermissionStatusColor("rejected", theme), textColor: getPermissionStatusTextColor("rejected", theme) },
|
||
{ label: "中断", value: abortedCount, color: getPermissionStatusColor("aborted", theme), textColor: getPermissionStatusTextColor("aborted", theme) },
|
||
];
|
||
const chipColor =
|
||
pendingCount > 0
|
||
? getPermissionStatusColor("pending", theme)
|
||
: abortedCount > 0
|
||
? getPermissionStatusColor("aborted", theme)
|
||
: rejectedCount > 0
|
||
? getPermissionStatusColor("rejected", theme)
|
||
: getPermissionStatusColor("approved_always", theme);
|
||
const chipTextColor =
|
||
pendingCount > 0
|
||
? getPermissionStatusTextColor("pending", theme)
|
||
: abortedCount > 0
|
||
? getPermissionStatusTextColor("aborted", theme)
|
||
: rejectedCount > 0
|
||
? getPermissionStatusTextColor("rejected", theme)
|
||
: getPermissionStatusTextColor("approved_always", theme);
|
||
|
||
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: "textColor" in item ? item.textColor : item.color,
|
||
fontSize: "0.7rem",
|
||
fontWeight: 800,
|
||
lineHeight: 1,
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
<Box
|
||
component="span"
|
||
sx={{
|
||
color: "textColor" in item ? item.textColor : item.color,
|
||
fontWeight: 700,
|
||
}}
|
||
>
|
||
{item.label}
|
||
</Box>
|
||
<Box component="span">{item.value} 项</Box>
|
||
</Box>
|
||
))}
|
||
</Stack>
|
||
</Box>
|
||
{isRunning && pendingCount > 0 ? (
|
||
<Chip
|
||
size="small"
|
||
label={`待确认 ${pendingCount} 项`}
|
||
sx={{
|
||
height: 24,
|
||
borderRadius: "12px",
|
||
fontSize: "0.7rem",
|
||
fontWeight: 800,
|
||
color: chipTextColor,
|
||
bgcolor: alpha(chipColor, 0.1),
|
||
"& .MuiChip-label": { px: 1 },
|
||
}}
|
||
/>
|
||
) : null}
|
||
<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 && isRunning && !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 = getPermissionStatusColor(permission.status, theme);
|
||
const itemTextColor = getPermissionStatusTextColor(permission.status, theme);
|
||
|
||
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: itemTextColor,
|
||
bgcolor: alpha(itemColor, 0.08),
|
||
"& .MuiChip-label": { px: 0.85 },
|
||
}}
|
||
/>
|
||
</Stack>
|
||
);
|
||
})}
|
||
</Stack>
|
||
) : null}
|
||
|
||
<AnimatePresence initial={false}>
|
||
{!expanded && isRunning && 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}
|
||
isRunning={isRunning}
|
||
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}
|
||
isRunning={isRunning}
|
||
onReply={onReply}
|
||
/>
|
||
))}
|
||
</Stack>
|
||
</Collapse>
|
||
</Box>
|
||
);
|
||
};
|