| New file |
| | |
| | | import React, { useEffect, useMemo, useState } from "react"; |
| | | import { |
| | | FilterButton, |
| | | List, |
| | | SearchInput, |
| | | SelectInput, |
| | | TextInput, |
| | | TopToolbar, |
| | | useListContext, |
| | | useNotify, |
| | | useTranslate, |
| | | } from "react-admin"; |
| | | import { |
| | | Alert, |
| | | Box, |
| | | Button, |
| | | Card, |
| | | CardActions, |
| | | CardContent, |
| | | Chip, |
| | | CircularProgress, |
| | | Dialog, |
| | | DialogActions, |
| | | DialogContent, |
| | | DialogTitle, |
| | | Divider, |
| | | Grid, |
| | | Stack, |
| | | Typography, |
| | | } from "@mui/material"; |
| | | import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; |
| | | import { getAiCallLogMcpLogs, getAiObserveStats } from "@/api/ai/observe"; |
| | | |
| | | const filters = [ |
| | | <SearchInput source="condition" alwaysOn />, |
| | | <TextInput source="requestId" label="ai.observe.fields.requestId" />, |
| | | <TextInput source="promptCode" label="ai.observe.fields.promptCode" />, |
| | | <TextInput source="userId" label="ai.observe.fields.userId" />, |
| | | <SelectInput |
| | | source="status" |
| | | label="common.field.status" |
| | | choices={[ |
| | | { id: "RUNNING", name: "RUNNING" }, |
| | | { id: "COMPLETED", name: "COMPLETED" }, |
| | | { id: "FAILED", name: "FAILED" }, |
| | | { id: "ABORTED", name: "ABORTED" }, |
| | | ]} |
| | | />, |
| | | ]; |
| | | |
| | | const ObserveSummary = () => { |
| | | const translate = useTranslate(); |
| | | const [stats, setStats] = useState(null); |
| | | const [loading, setLoading] = useState(true); |
| | | const [error, setError] = useState(""); |
| | | |
| | | useEffect(() => { |
| | | let active = true; |
| | | setLoading(true); |
| | | getAiObserveStats() |
| | | .then((data) => { |
| | | if (active) { |
| | | setStats(data); |
| | | setError(""); |
| | | } |
| | | }) |
| | | .catch((err) => { |
| | | if (active) { |
| | | setError(err?.message || translate("ai.observe.summary.fetchFailed")); |
| | | } |
| | | }) |
| | | .finally(() => { |
| | | if (active) { |
| | | setLoading(false); |
| | | } |
| | | }); |
| | | return () => { |
| | | active = false; |
| | | }; |
| | | }, []); |
| | | |
| | | return ( |
| | | <Box px={2} pt={2}> |
| | | <Card variant="outlined" sx={{ borderRadius: 3, boxShadow: "0 8px 24px rgba(15, 23, 42, 0.06)" }}> |
| | | <CardContent> |
| | | <Stack direction="row" justifyContent="space-between" alignItems="center" mb={2}> |
| | | <Box> |
| | | <Typography variant="h6">{translate("ai.observe.summary.title")}</Typography> |
| | | <Typography variant="body2" color="text.secondary"> |
| | | {translate("ai.observe.summary.description")} |
| | | </Typography> |
| | | </Box> |
| | | {loading && <CircularProgress size={24} />} |
| | | </Stack> |
| | | {error && <Alert severity="error">{error}</Alert>} |
| | | {!loading && !error && stats && ( |
| | | <Grid container spacing={2}> |
| | | <Grid item xs={12} md={3}> |
| | | <Typography variant="caption" color="text.secondary">{translate("ai.observe.summary.callCount")}</Typography> |
| | | <Typography variant="h5">{stats.callCount ?? 0}</Typography> |
| | | <Typography variant="body2" color="text.secondary"> |
| | | {translate("ai.observe.summary.successFailure", { success: stats.successCount ?? 0, failure: stats.failureCount ?? 0 })} |
| | | </Typography> |
| | | </Grid> |
| | | <Grid item xs={12} md={3}> |
| | | <Typography variant="caption" color="text.secondary">{translate("ai.observe.summary.avgElapsed")}</Typography> |
| | | <Typography variant="h5">{stats.avgElapsedMs ?? 0} ms</Typography> |
| | | <Typography variant="body2" color="text.secondary"> |
| | | {translate("ai.observe.summary.firstToken", { value: stats.avgFirstTokenLatencyMs ?? 0 })} |
| | | </Typography> |
| | | </Grid> |
| | | <Grid item xs={12} md={3}> |
| | | <Typography variant="caption" color="text.secondary">{translate("ai.observe.summary.tokenUsage")}</Typography> |
| | | <Typography variant="h5">{stats.totalTokens ?? 0}</Typography> |
| | | <Typography variant="body2" color="text.secondary"> |
| | | {translate("ai.observe.summary.avgToken", { value: stats.avgTotalTokens ?? 0 })} |
| | | </Typography> |
| | | </Grid> |
| | | <Grid item xs={12} md={3}> |
| | | <Typography variant="caption" color="text.secondary">{translate("ai.observe.summary.toolSuccessRate")}</Typography> |
| | | <Typography variant="h5">{Number(stats.toolSuccessRate || 0).toFixed(2)}%</Typography> |
| | | <Typography variant="body2" color="text.secondary"> |
| | | {translate("ai.observe.summary.toolCallFailure", { call: stats.toolCallCount ?? 0, failure: stats.toolFailureCount ?? 0 })} |
| | | </Typography> |
| | | </Grid> |
| | | </Grid> |
| | | )} |
| | | </CardContent> |
| | | </Card> |
| | | </Box> |
| | | ); |
| | | }; |
| | | |
| | | const resolveStatusChip = (status, translate) => { |
| | | if (status === "COMPLETED") { |
| | | return { color: "success", label: translate("ai.observe.status.completed") }; |
| | | } |
| | | if (status === "FAILED") { |
| | | return { color: "error", label: translate("ai.observe.status.failed") }; |
| | | } |
| | | if (status === "ABORTED") { |
| | | return { color: "warning", label: translate("ai.observe.status.aborted") }; |
| | | } |
| | | return { color: "default", label: status || "--" }; |
| | | }; |
| | | |
| | | const AiCallLogDetailDialog = ({ record, open, onClose }) => { |
| | | const notify = useNotify(); |
| | | const translate = useTranslate(); |
| | | const [logs, setLogs] = useState([]); |
| | | const [loading, setLoading] = useState(false); |
| | | |
| | | useEffect(() => { |
| | | if (!open || !record?.id) { |
| | | return; |
| | | } |
| | | let active = true; |
| | | setLoading(true); |
| | | getAiCallLogMcpLogs(record.id) |
| | | .then((data) => { |
| | | if (active) { |
| | | setLogs(data); |
| | | } |
| | | }) |
| | | .catch((error) => { |
| | | if (active) { |
| | | notify(error?.message || translate("ai.observe.detail.mcpLogsFailed"), { type: "error" }); |
| | | } |
| | | }) |
| | | .finally(() => { |
| | | if (active) { |
| | | setLoading(false); |
| | | } |
| | | }); |
| | | return () => { |
| | | active = false; |
| | | }; |
| | | }, [open, record, notify]); |
| | | |
| | | if (!record) { |
| | | return null; |
| | | } |
| | | |
| | | return ( |
| | | <Dialog open={open} onClose={onClose} fullWidth maxWidth="lg"> |
| | | <DialogTitle>{translate("ai.observe.detail.title")}</DialogTitle> |
| | | <DialogContent dividers> |
| | | <Grid container spacing={2}> |
| | | <Grid item xs={12} md={6}> |
| | | <Typography variant="caption" color="text.secondary">{translate("ai.observe.fields.requestId")}</Typography> |
| | | <Typography variant="body2">{record.requestId || "--"}</Typography> |
| | | </Grid> |
| | | <Grid item xs={12} md={3}> |
| | | <Typography variant="caption" color="text.secondary">{translate("ai.observe.fields.userId")}</Typography> |
| | | <Typography variant="body2">{record.userId || "--"}</Typography> |
| | | </Grid> |
| | | <Grid item xs={12} md={3}> |
| | | <Typography variant="caption" color="text.secondary">{translate("ai.observe.fields.sessionId")}</Typography> |
| | | <Typography variant="body2">{record.sessionId || "--"}</Typography> |
| | | </Grid> |
| | | <Grid item xs={12} md={4}> |
| | | <Typography variant="caption" color="text.secondary">{translate("ai.common.prompt")}</Typography> |
| | | <Typography variant="body2">{record.promptName || "--"} / {record.promptCode || "--"}</Typography> |
| | | </Grid> |
| | | <Grid item xs={12} md={4}> |
| | | <Typography variant="caption" color="text.secondary">{translate("ai.common.model")}</Typography> |
| | | <Typography variant="body2">{record.model || "--"}</Typography> |
| | | </Grid> |
| | | <Grid item xs={12} md={4}> |
| | | <Typography variant="caption" color="text.secondary">{translate("common.field.status")}</Typography> |
| | | <Typography variant="body2">{record.status || "--"}</Typography> |
| | | </Grid> |
| | | <Grid item xs={12}> |
| | | <Typography variant="caption" color="text.secondary">{translate("ai.observe.fields.mountedMcp")}</Typography> |
| | | <Typography variant="body2">{record.mountedMcpNames || "--"}</Typography> |
| | | </Grid> |
| | | {record.errorMessage && ( |
| | | <Grid item xs={12}> |
| | | <Alert severity="error">{record.errorMessage}</Alert> |
| | | </Grid> |
| | | )} |
| | | <Grid item xs={12}> |
| | | <Divider sx={{ my: 1 }} /> |
| | | <Stack direction="row" justifyContent="space-between" alignItems="center" mb={1}> |
| | | <Typography variant="h6">{translate("ai.observe.detail.mcpLogs")}</Typography> |
| | | {loading && <CircularProgress size={20} />} |
| | | </Stack> |
| | | {!loading && !logs.length && ( |
| | | <Typography variant="body2" color="text.secondary"> |
| | | {translate("ai.observe.detail.noMcpLogs")} |
| | | </Typography> |
| | | )} |
| | | <Stack spacing={1.5}> |
| | | {logs.map((item) => ( |
| | | <Card key={item.id} variant="outlined"> |
| | | <CardContent> |
| | | <Stack direction="row" justifyContent="space-between" alignItems="center" mb={1}> |
| | | <Box> |
| | | <Typography variant="subtitle2">{item.toolName || "--"}</Typography> |
| | | <Typography variant="body2" color="text.secondary"> |
| | | {item.mountName || "--"} · {item.createTime$ || "--"} |
| | | </Typography> |
| | | </Box> |
| | | <Chip |
| | | size="small" |
| | | color={item.status === "COMPLETED" ? "success" : "error"} |
| | | label={item.status || "--"} |
| | | /> |
| | | </Stack> |
| | | <Typography variant="caption" color="text.secondary">{translate("ai.observe.detail.inputSummary")}</Typography> |
| | | <Typography variant="body2" sx={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}> |
| | | {item.inputSummary || "--"} |
| | | </Typography> |
| | | <Typography variant="caption" color="text.secondary" display="block" mt={1}>{translate("ai.observe.detail.outputSummary")}</Typography> |
| | | <Typography variant="body2" sx={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}> |
| | | {item.outputSummary || item.errorMessage || "--"} |
| | | </Typography> |
| | | </CardContent> |
| | | </Card> |
| | | ))} |
| | | </Stack> |
| | | </Grid> |
| | | </Grid> |
| | | </DialogContent> |
| | | <DialogActions> |
| | | <Button onClick={onClose}>{translate("ai.common.close")}</Button> |
| | | </DialogActions> |
| | | </Dialog> |
| | | ); |
| | | }; |
| | | |
| | | const AiCallLogCards = ({ onView }) => { |
| | | const translate = useTranslate(); |
| | | const { data, isLoading } = useListContext(); |
| | | const records = useMemo(() => (Array.isArray(data) ? data : []), [data]); |
| | | |
| | | if (isLoading) { |
| | | return ( |
| | | <Box display="flex" justifyContent="center" py={8}> |
| | | <CircularProgress size={28} /> |
| | | </Box> |
| | | ); |
| | | } |
| | | |
| | | if (!records.length) { |
| | | return ( |
| | | <Box px={2} py={6}> |
| | | <Card variant="outlined" sx={{ p: 3, textAlign: "center", borderStyle: "dashed" }}> |
| | | <Typography variant="subtitle1">{translate("ai.observe.list.emptyTitle")}</Typography> |
| | | <Typography variant="body2" color="text.secondary" mt={1}> |
| | | {translate("ai.observe.list.emptyDescription")} |
| | | </Typography> |
| | | </Card> |
| | | </Box> |
| | | ); |
| | | } |
| | | |
| | | return ( |
| | | <Box px={2} py={2}> |
| | | <Grid container spacing={2}> |
| | | {records.map((record) => { |
| | | const statusMeta = resolveStatusChip(record.status, translate); |
| | | return ( |
| | | <Grid item xs={12} md={6} xl={4} key={record.id}> |
| | | <Card variant="outlined" sx={{ height: "100%", borderRadius: 3, boxShadow: "0 8px 24px rgba(15, 23, 42, 0.06)" }}> |
| | | <CardContent sx={{ pb: 1.5 }}> |
| | | <Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}> |
| | | <Box> |
| | | <Typography variant="h6">{record.promptName || translate("ai.drawer.title")}</Typography> |
| | | <Typography variant="body2" color="text.secondary"> |
| | | {record.promptCode || "--"} / {record.model || "--"} |
| | | </Typography> |
| | | </Box> |
| | | <Chip size="small" color={statusMeta.color} label={statusMeta.label} /> |
| | | </Stack> |
| | | <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap mt={1.5}> |
| | | <Chip size="small" variant="outlined" label={translate("ai.observe.list.userValue", { value: record.userId || "--" })} /> |
| | | <Chip size="small" variant="outlined" label={translate("ai.observe.list.elapsedValue", { value: record.elapsedMs ?? 0 })} /> |
| | | <Chip size="small" variant="outlined" label={translate("ai.observe.list.tokenValue", { value: record.totalTokens ?? 0 })} /> |
| | | </Stack> |
| | | <Divider sx={{ my: 1.5 }} /> |
| | | <Typography variant="caption" color="text.secondary">{translate("ai.observe.fields.requestId")}</Typography> |
| | | <Typography variant="body2" sx={{ wordBreak: "break-all" }}>{record.requestId || "--"}</Typography> |
| | | <Typography variant="caption" color="text.secondary" display="block" mt={1.5}>{translate("ai.observe.list.mcpToolCalls")}</Typography> |
| | | <Typography variant="body2"> |
| | | {translate("ai.observe.list.mcpToolSummary", { |
| | | mcp: record.mountedMcpCount ?? 0, |
| | | success: record.toolSuccessCount ?? 0, |
| | | failure: record.toolFailureCount ?? 0, |
| | | })} |
| | | </Typography> |
| | | {record.errorMessage && ( |
| | | <> |
| | | <Typography variant="caption" color="text.secondary" display="block" mt={1.5}>{translate("ai.common.error")}</Typography> |
| | | <Typography variant="body2">{record.errorMessage}</Typography> |
| | | </> |
| | | )} |
| | | </CardContent> |
| | | <CardActions sx={{ px: 2, pb: 2, pt: 0 }}> |
| | | <Button size="small" startIcon={<VisibilityOutlinedIcon />} onClick={() => onView(record)}> |
| | | {translate("ai.common.detail")} |
| | | </Button> |
| | | </CardActions> |
| | | </Card> |
| | | </Grid> |
| | | ); |
| | | })} |
| | | </Grid> |
| | | </Box> |
| | | ); |
| | | }; |
| | | |
| | | const AiCallLogList = () => { |
| | | const [selectedRecord, setSelectedRecord] = useState(null); |
| | | |
| | | return ( |
| | | <> |
| | | <List |
| | | title="menu.aiCallLog" |
| | | filters={filters} |
| | | sort={{ field: "create_time", order: "desc" }} |
| | | actions={( |
| | | <TopToolbar> |
| | | <FilterButton /> |
| | | </TopToolbar> |
| | | )} |
| | | > |
| | | <ObserveSummary /> |
| | | <AiCallLogCards onView={setSelectedRecord} /> |
| | | </List> |
| | | <AiCallLogDetailDialog |
| | | record={selectedRecord} |
| | | open={Boolean(selectedRecord)} |
| | | onClose={() => setSelectedRecord(null)} |
| | | /> |
| | | </> |
| | | ); |
| | | }; |
| | | |
| | | export default AiCallLogList; |