From d5884d0974d17d96225a5d80e432de33a5ee6552 Mon Sep 17 00:00:00 2001
From: zhou zhou <3272660260@qq.com>
Date: 星期四, 19 三月 2026 13:10:21 +0800
Subject: [PATCH] #AI.日志与审计

---
 rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/McpMountRuntimeFactoryImpl.java |   62 ++
 rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiObserveStatsDto.java                   |   31 +
 rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiCallLogMapper.java                  |   11 
 version/db/ai_feature.sql                                                                       |   61 ++
 rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiMcpCallLog.java                     |   76 +++
 rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiCallLogController.java          |   76 +++
 rsf-admin/src/config/authProvider.js                                                            |    1 
 rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiMcpCallLogMapper.java               |   11 
 rsf-server/src/main/java/com/vincent/rsf/server/ai/service/MountedToolCallback.java             |    8 
 rsf-admin/src/api/ai/observe.js                                                                 |   19 
 rsf-admin/src/page/system/aiCallLog/AiCallLogList.jsx                                           |  378 ++++++++++++++++++
 rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatToolEventDto.java                  |    2 
 rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiCallLogServiceImpl.java       |  184 +++++++++
 rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java          |  116 +++++
 rsf-admin/src/i18n/zh.js                                                                        |    1 
 rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiCallLog.java                        |  118 +++++
 rsf-admin/src/i18n/en.js                                                                        |    1 
 rsf-admin/src/page/ResourceContent.js                                                           |    3 
 rsf-admin/src/page/system/aiCallLog/index.jsx                                                   |    6 
 rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiCallLogService.java                |   30 +
 20 files changed, 1,177 insertions(+), 18 deletions(-)

diff --git a/rsf-admin/src/api/ai/observe.js b/rsf-admin/src/api/ai/observe.js
new file mode 100644
index 0000000..cd659c5
--- /dev/null
+++ b/rsf-admin/src/api/ai/observe.js
@@ -0,0 +1,19 @@
+import request from "@/utils/request";
+
+export const getAiObserveStats = async () => {
+    const res = await request.get("aiCallLog/stats");
+    const { code, msg, data } = res.data;
+    if (code === 200) {
+        return data;
+    }
+    throw new Error(msg || "鑾峰彇 AI 瑙傛祴缁熻澶辫触");
+};
+
+export const getAiCallLogMcpLogs = async (callLogId) => {
+    const res = await request.get(`aiCallLog/${callLogId}/mcpLogs`);
+    const { code, msg, data } = res.data;
+    if (code === 200) {
+        return data || [];
+    }
+    throw new Error(msg || "鑾峰彇 MCP 璋冪敤鏃ュ織澶辫触");
+};
diff --git a/rsf-admin/src/config/authProvider.js b/rsf-admin/src/config/authProvider.js
index 24655a3..72292d2 100644
--- a/rsf-admin/src/config/authProvider.js
+++ b/rsf-admin/src/config/authProvider.js
@@ -7,7 +7,6 @@
 const AI_COMPONENTS = new Set([
   'aiDiagnosis',
   'aiDiagnosisPlan',
-  'aiCallLog',
   'aiRoute',
   'aiToolConfig',
 ]);
diff --git a/rsf-admin/src/i18n/en.js b/rsf-admin/src/i18n/en.js
index 1eaeaa2..f0b39e1 100644
--- a/rsf-admin/src/i18n/en.js
+++ b/rsf-admin/src/i18n/en.js
@@ -153,6 +153,7 @@
         aiParam: 'AI Params',
         aiPrompt: 'Prompts',
         aiMcpMount: 'MCP Mounts',
+        aiCallLog: 'AI Observe',
         tenant: 'Tenant',
         userLogin: 'Token',
         customer: 'Customer',
diff --git a/rsf-admin/src/i18n/zh.js b/rsf-admin/src/i18n/zh.js
index 95e5cb2..7998128 100644
--- a/rsf-admin/src/i18n/zh.js
+++ b/rsf-admin/src/i18n/zh.js
@@ -154,6 +154,7 @@
         aiParam: 'AI 鍙傛暟',
         aiPrompt: 'Prompt 绠$悊',
         aiMcpMount: 'MCP 鎸傝浇',
+        aiCallLog: 'AI 瑙傛祴',
         tenant: '绉熸埛绠$悊',
         userLogin: '鐧诲綍鏃ュ織',
         customer: '瀹㈡埛琛�',
diff --git a/rsf-admin/src/page/ResourceContent.js b/rsf-admin/src/page/ResourceContent.js
index 910f2c5..c4b4beb 100644
--- a/rsf-admin/src/page/ResourceContent.js
+++ b/rsf-admin/src/page/ResourceContent.js
@@ -73,6 +73,7 @@
 import aiParam from "./system/aiParam";
 import aiPrompt from "./system/aiPrompt";
 import aiMcpMount from "./system/aiMcpMount";
+import aiCallLog from "./system/aiCallLog";
 
 const ResourceContent = (node) => {
   switch (node.component) {
@@ -214,6 +215,8 @@
       return aiPrompt;
     case "aiMcpMount":
       return aiMcpMount;
+    case "aiCallLog":
+      return aiCallLog;
     // case "locItem":
     //   return locItem;
     default:
diff --git a/rsf-admin/src/page/system/aiCallLog/AiCallLogList.jsx b/rsf-admin/src/page/system/aiCallLog/AiCallLogList.jsx
new file mode 100644
index 0000000..abb70b1
--- /dev/null
+++ b/rsf-admin/src/page/system/aiCallLog/AiCallLogList.jsx
@@ -0,0 +1,378 @@
+import React, { useEffect, useMemo, useState } from "react";
+import {
+    FilterButton,
+    List,
+    SearchInput,
+    SelectInput,
+    TextInput,
+    TopToolbar,
+    useListContext,
+    useNotify,
+} 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="璇锋眰ID" />,
+    <TextInput source="promptCode" label="Prompt 缂栫爜" />,
+    <TextInput source="userId" label="鐢ㄦ埛ID" />,
+    <SelectInput
+        source="status"
+        label="鐘舵��"
+        choices={[
+            { id: "RUNNING", name: "RUNNING" },
+            { id: "COMPLETED", name: "COMPLETED" },
+            { id: "FAILED", name: "FAILED" },
+            { id: "ABORTED", name: "ABORTED" },
+        ]}
+    />,
+];
+
+const ObserveSummary = () => {
+    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 || "鑾峰彇 AI 瑙傛祴缁熻澶辫触");
+                }
+            })
+            .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">瑙傛祴鎬昏</Typography>
+                            <Typography variant="body2" color="text.secondary">
+                                褰撳墠绉熸埛涓嬬殑 AI 瀵硅瘽璋冪敤涓� MCP 宸ュ叿璋冪敤缁熻銆�
+                            </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">AI 璋冪敤閲�</Typography>
+                                <Typography variant="h5">{stats.callCount ?? 0}</Typography>
+                                <Typography variant="body2" color="text.secondary">
+                                    鎴愬姛 {stats.successCount ?? 0} / 澶辫触 {stats.failureCount ?? 0}
+                                </Typography>
+                            </Grid>
+                            <Grid item xs={12} md={3}>
+                                <Typography variant="caption" color="text.secondary">骞冲潎鑰楁椂</Typography>
+                                <Typography variant="h5">{stats.avgElapsedMs ?? 0} ms</Typography>
+                                <Typography variant="body2" color="text.secondary">
+                                    棣栧寘 {stats.avgFirstTokenLatencyMs ?? 0} ms
+                                </Typography>
+                            </Grid>
+                            <Grid item xs={12} md={3}>
+                                <Typography variant="caption" color="text.secondary">Token 浣跨敤</Typography>
+                                <Typography variant="h5">{stats.totalTokens ?? 0}</Typography>
+                                <Typography variant="body2" color="text.secondary">
+                                    骞冲潎 {stats.avgTotalTokens ?? 0}
+                                </Typography>
+                            </Grid>
+                            <Grid item xs={12} md={3}>
+                                <Typography variant="caption" color="text.secondary">宸ュ叿鎴愬姛鐜�</Typography>
+                                <Typography variant="h5">{Number(stats.toolSuccessRate || 0).toFixed(2)}%</Typography>
+                                <Typography variant="body2" color="text.secondary">
+                                    璋冪敤 {stats.toolCallCount ?? 0} / 澶辫触 {stats.toolFailureCount ?? 0}
+                                </Typography>
+                            </Grid>
+                        </Grid>
+                    )}
+                </CardContent>
+            </Card>
+        </Box>
+    );
+};
+
+const resolveStatusChip = (status) => {
+    if (status === "COMPLETED") {
+        return { color: "success", label: "鎴愬姛" };
+    }
+    if (status === "FAILED") {
+        return { color: "error", label: "澶辫触" };
+    }
+    if (status === "ABORTED") {
+        return { color: "warning", label: "涓柇" };
+    }
+    return { color: "default", label: status || "--" };
+};
+
+const AiCallLogDetailDialog = ({ record, open, onClose }) => {
+    const notify = useNotify();
+    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 || "鑾峰彇 MCP 璋冪敤鏃ュ織澶辫触", { 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>AI 璋冪敤璇︽儏</DialogTitle>
+            <DialogContent dividers>
+                <Grid container spacing={2}>
+                    <Grid item xs={12} md={6}>
+                        <Typography variant="caption" color="text.secondary">璇锋眰ID</Typography>
+                        <Typography variant="body2">{record.requestId || "--"}</Typography>
+                    </Grid>
+                    <Grid item xs={12} md={3}>
+                        <Typography variant="caption" color="text.secondary">鐢ㄦ埛ID</Typography>
+                        <Typography variant="body2">{record.userId || "--"}</Typography>
+                    </Grid>
+                    <Grid item xs={12} md={3}>
+                        <Typography variant="caption" color="text.secondary">浼氳瘽ID</Typography>
+                        <Typography variant="body2">{record.sessionId || "--"}</Typography>
+                    </Grid>
+                    <Grid item xs={12} md={4}>
+                        <Typography variant="caption" color="text.secondary">Prompt</Typography>
+                        <Typography variant="body2">{record.promptName || "--"} / {record.promptCode || "--"}</Typography>
+                    </Grid>
+                    <Grid item xs={12} md={4}>
+                        <Typography variant="caption" color="text.secondary">妯″瀷</Typography>
+                        <Typography variant="body2">{record.model || "--"}</Typography>
+                    </Grid>
+                    <Grid item xs={12} md={4}>
+                        <Typography variant="caption" color="text.secondary">鐘舵��</Typography>
+                        <Typography variant="body2">{record.status || "--"}</Typography>
+                    </Grid>
+                    <Grid item xs={12}>
+                        <Typography variant="caption" color="text.secondary">MCP 鎸傝浇</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">MCP 宸ュ叿璋冪敤鏃ュ織</Typography>
+                            {loading && <CircularProgress size={20} />}
+                        </Stack>
+                        {!loading && !logs.length && (
+                            <Typography variant="body2" color="text.secondary">
+                                褰撳墠璋冪敤娌℃湁浜х敓 MCP 宸ュ叿鏃ュ織銆�
+                            </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">杈撳叆鎽樿</Typography>
+                                        <Typography variant="body2" sx={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
+                                            {item.inputSummary || "--"}
+                                        </Typography>
+                                        <Typography variant="caption" color="text.secondary" display="block" mt={1}>
+                                            杈撳嚭鎽樿 / 閿欒
+                                        </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}>鍏抽棴</Button>
+            </DialogActions>
+        </Dialog>
+    );
+};
+
+const AiCallLogCards = ({ onView }) => {
+    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">鏆傛棤 AI 璋冪敤鏃ュ織</Typography>
+                    <Typography variant="body2" color="text.secondary" mt={1}>
+                        鍙戣捣 AI 瀵硅瘽鍚庯紝杩欓噷浼氬睍绀鸿皟鐢ㄧ粺璁″拰瀹¤璁板綍銆�
+                    </Typography>
+                </Card>
+            </Box>
+        );
+    }
+
+    return (
+        <Box px={2} py={2}>
+            <Grid container spacing={2}>
+                {records.map((record) => {
+                    const statusMeta = resolveStatusChip(record.status);
+                    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 || "AI 瀵硅瘽"}</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={`鐢ㄦ埛 ${record.userId || "--"}`} />
+                                        <Chip size="small" variant="outlined" label={`鑰楁椂 ${record.elapsedMs ?? 0} ms`} />
+                                        <Chip size="small" variant="outlined" label={`Token ${record.totalTokens ?? 0}`} />
+                                    </Stack>
+                                    <Divider sx={{ my: 1.5 }} />
+                                    <Typography variant="caption" color="text.secondary">璇锋眰ID</Typography>
+                                    <Typography variant="body2" sx={{ wordBreak: "break-all" }}>{record.requestId || "--"}</Typography>
+                                    <Typography variant="caption" color="text.secondary" display="block" mt={1.5}>
+                                        MCP / 宸ュ叿璋冪敤
+                                    </Typography>
+                                    <Typography variant="body2">
+                                        鎸傝浇 {record.mountedMcpCount ?? 0} 涓紝宸ュ叿鎴愬姛 {record.toolSuccessCount ?? 0}锛屽け璐� {record.toolFailureCount ?? 0}
+                                    </Typography>
+                                    {record.errorMessage && (
+                                        <>
+                                            <Typography variant="caption" color="text.secondary" display="block" mt={1.5}>
+                                                閿欒
+                                            </Typography>
+                                            <Typography variant="body2">{record.errorMessage}</Typography>
+                                        </>
+                                    )}
+                                </CardContent>
+                                <CardActions sx={{ px: 2, pb: 2, pt: 0 }}>
+                                    <Button size="small" startIcon={<VisibilityOutlinedIcon />} onClick={() => onView(record)}>
+                                        璇︽儏
+                                    </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;
diff --git a/rsf-admin/src/page/system/aiCallLog/index.jsx b/rsf-admin/src/page/system/aiCallLog/index.jsx
new file mode 100644
index 0000000..ec8fe57
--- /dev/null
+++ b/rsf-admin/src/page/system/aiCallLog/index.jsx
@@ -0,0 +1,6 @@
+import AiCallLogList from "./AiCallLogList";
+
+export default {
+    list: AiCallLogList,
+    recordRepresentation: (record) => `${record?.requestId || ""}`,
+};
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiCallLogController.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiCallLogController.java
new file mode 100644
index 0000000..4a26db2
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/controller/AiCallLogController.java
@@ -0,0 +1,76 @@
+package com.vincent.rsf.server.ai.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.vincent.rsf.framework.common.R;
+import com.vincent.rsf.server.ai.entity.AiCallLog;
+import com.vincent.rsf.server.ai.service.AiCallLogService;
+import com.vincent.rsf.server.common.domain.BaseParam;
+import com.vincent.rsf.server.common.domain.PageParam;
+import com.vincent.rsf.server.system.controller.BaseController;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Arrays;
+import java.util.Map;
+
+@RestController
+@RequiredArgsConstructor
+public class AiCallLogController extends BaseController {
+
+    private final AiCallLogService aiCallLogService;
+
+    @PreAuthorize("hasAuthority('system:aiCallLog:list')")
+    @PostMapping("/aiCallLog/page")
+    public R page(@RequestBody Map<String, Object> map) {
+        BaseParam baseParam = buildParam(map, BaseParam.class);
+        PageParam<AiCallLog, BaseParam> pageParam = new PageParam<>(baseParam, AiCallLog.class);
+        return R.ok().add(aiCallLogService.page(pageParam, pageParam.buildWrapper(true)
+                .eq("tenant_id", getTenantId())
+                .eq("deleted", 0)));
+    }
+
+    @PreAuthorize("hasAuthority('system:aiCallLog:list')")
+    @PostMapping("/aiCallLog/list")
+    public R list(@RequestBody Map<String, Object> map) {
+        return R.ok().add(aiCallLogService.list(new LambdaQueryWrapper<AiCallLog>()
+                .eq(AiCallLog::getTenantId, getTenantId())
+                .eq(AiCallLog::getDeleted, 0)
+                .orderByDesc(AiCallLog::getId)));
+    }
+
+    @PreAuthorize("hasAuthority('system:aiCallLog:list')")
+    @PostMapping({"/aiCallLog/many/{ids}", "/aiCallLogs/many/{ids}"})
+    public R many(@PathVariable Long[] ids) {
+        return R.ok().add(aiCallLogService.list(new LambdaQueryWrapper<AiCallLog>()
+                .eq(AiCallLog::getTenantId, getTenantId())
+                .eq(AiCallLog::getDeleted, 0)
+                .in(AiCallLog::getId, Arrays.asList(ids))));
+    }
+
+    @PreAuthorize("hasAuthority('system:aiCallLog:list')")
+    @GetMapping("/aiCallLog/{id}")
+    public R get(@PathVariable("id") Long id) {
+        return R.ok().add(aiCallLogService.getOne(new LambdaQueryWrapper<AiCallLog>()
+                .eq(AiCallLog::getId, id)
+                .eq(AiCallLog::getTenantId, getTenantId())
+                .eq(AiCallLog::getDeleted, 0)
+                .last("limit 1")));
+    }
+
+    @PreAuthorize("hasAuthority('system:aiCallLog:list')")
+    @GetMapping("/aiCallLog/stats")
+    public R stats() {
+        return R.ok().add(aiCallLogService.getObserveStats(getTenantId()));
+    }
+
+    @PreAuthorize("hasAuthority('system:aiCallLog:list')")
+    @GetMapping("/aiCallLog/{id}/mcpLogs")
+    public R listMcpLogs(@PathVariable("id") Long id) {
+        return R.ok().add(aiCallLogService.listMcpLogs(id, getTenantId()));
+    }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatToolEventDto.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatToolEventDto.java
index c4274b8..dbeb6de 100644
--- a/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatToolEventDto.java
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatToolEventDto.java
@@ -15,6 +15,8 @@
 
     private String toolName;
 
+    private String mountName;
+
     private String status;
 
     private String inputSummary;
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiObserveStatsDto.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiObserveStatsDto.java
new file mode 100644
index 0000000..6bd71bc
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiObserveStatsDto.java
@@ -0,0 +1,31 @@
+package com.vincent.rsf.server.ai.dto;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class AiObserveStatsDto {
+
+    private Long callCount;
+
+    private Long successCount;
+
+    private Long failureCount;
+
+    private Long avgElapsedMs;
+
+    private Long avgFirstTokenLatencyMs;
+
+    private Long totalTokens;
+
+    private Long avgTotalTokens;
+
+    private Long toolCallCount;
+
+    private Long toolSuccessCount;
+
+    private Long toolFailureCount;
+
+    private Double toolSuccessRate;
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiCallLog.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiCallLog.java
new file mode 100644
index 0000000..a6cda23
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiCallLog.java
@@ -0,0 +1,118 @@
+package com.vincent.rsf.server.ai.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.experimental.Accessors;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.io.Serializable;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+@Data
+@Accessors(chain = true)
+@TableName("sys_ai_call_log")
+public class AiCallLog implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    @ApiModelProperty("璇锋眰ID")
+    private String requestId;
+
+    @ApiModelProperty("浼氳瘽ID")
+    private Long sessionId;
+
+    @ApiModelProperty("Prompt缂栫爜")
+    private String promptCode;
+
+    @ApiModelProperty("Prompt鍚嶇О")
+    private String promptName;
+
+    @ApiModelProperty("妯″瀷")
+    private String model;
+
+    @ApiModelProperty("鐢ㄦ埛ID")
+    private Long userId;
+
+    @ApiModelProperty("绉熸埛ID")
+    private Long tenantId;
+
+    @ApiModelProperty("鐘舵��")
+    private String status;
+
+    @ApiModelProperty("閿欒鍒嗙被")
+    private String errorCategory;
+
+    @ApiModelProperty("閿欒闃舵")
+    private String errorStage;
+
+    @ApiModelProperty("閿欒淇℃伅")
+    private String errorMessage;
+
+    @ApiModelProperty("閰嶇疆MCP鏁伴噺")
+    private Integer configuredMcpCount;
+
+    @ApiModelProperty("鎸傝浇MCP鏁伴噺")
+    private Integer mountedMcpCount;
+
+    @ApiModelProperty("鎸傝浇MCP鍚嶇О")
+    private String mountedMcpNames;
+
+    @ApiModelProperty("宸ュ叿璋冪敤鎬绘暟")
+    private Integer toolCallCount;
+
+    @ApiModelProperty("宸ュ叿鎴愬姛鏁�")
+    private Integer toolSuccessCount;
+
+    @ApiModelProperty("宸ュ叿澶辫触鏁�")
+    private Integer toolFailureCount;
+
+    @ApiModelProperty("鎬昏�楁椂")
+    private Long elapsedMs;
+
+    @ApiModelProperty("棣栧寘鑰楁椂")
+    private Long firstTokenLatencyMs;
+
+    @ApiModelProperty("Prompt Tokens")
+    private Integer promptTokens;
+
+    @ApiModelProperty("Completion Tokens")
+    private Integer completionTokens;
+
+    @ApiModelProperty("Total Tokens")
+    private Integer totalTokens;
+
+    @ApiModelProperty("鏄惁鍒犻櫎")
+    private Integer deleted;
+
+    @ApiModelProperty("鍒涘缓鏃堕棿")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date createTime;
+
+    @ApiModelProperty("鏇存柊鏃堕棿")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date updateTime;
+
+    public String getCreateTime$() {
+        if (this.createTime == null) {
+            return "";
+        }
+        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.createTime);
+    }
+
+    public String getUpdateTime$() {
+        if (this.updateTime == null) {
+            return "";
+        }
+        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.updateTime);
+    }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiMcpCallLog.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiMcpCallLog.java
new file mode 100644
index 0000000..c4a34bf
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/entity/AiMcpCallLog.java
@@ -0,0 +1,76 @@
+package com.vincent.rsf.server.ai.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import lombok.experimental.Accessors;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.io.Serializable;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+@Data
+@Accessors(chain = true)
+@TableName("sys_ai_mcp_call_log")
+public class AiMcpCallLog implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    @ApiModelProperty("AI璋冪敤鏃ュ織ID")
+    private Long callLogId;
+
+    @ApiModelProperty("璇锋眰ID")
+    private String requestId;
+
+    @ApiModelProperty("浼氳瘽ID")
+    private Long sessionId;
+
+    @ApiModelProperty("宸ュ叿璋冪敤ID")
+    private String toolCallId;
+
+    @ApiModelProperty("鎸傝浇鍚嶇О")
+    private String mountName;
+
+    @ApiModelProperty("宸ュ叿鍚嶇О")
+    private String toolName;
+
+    @ApiModelProperty("鐘舵��")
+    private String status;
+
+    @ApiModelProperty("杈撳叆鎽樿")
+    private String inputSummary;
+
+    @ApiModelProperty("杈撳嚭鎽樿")
+    private String outputSummary;
+
+    @ApiModelProperty("閿欒淇℃伅")
+    private String errorMessage;
+
+    @ApiModelProperty("鑰楁椂")
+    private Long durationMs;
+
+    @ApiModelProperty("鐢ㄦ埛ID")
+    private Long userId;
+
+    @ApiModelProperty("绉熸埛ID")
+    private Long tenantId;
+
+    @ApiModelProperty("鍒涘缓鏃堕棿")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date createTime;
+
+    public String getCreateTime$() {
+        if (this.createTime == null) {
+            return "";
+        }
+        return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.createTime);
+    }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiCallLogMapper.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiCallLogMapper.java
new file mode 100644
index 0000000..8b3dd47
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiCallLogMapper.java
@@ -0,0 +1,11 @@
+package com.vincent.rsf.server.ai.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.vincent.rsf.server.ai.entity.AiCallLog;
+import org.apache.ibatis.annotations.Mapper;
+import org.springframework.stereotype.Repository;
+
+@Mapper
+@Repository
+public interface AiCallLogMapper extends BaseMapper<AiCallLog> {
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiMcpCallLogMapper.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiMcpCallLogMapper.java
new file mode 100644
index 0000000..b224cff
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/mapper/AiMcpCallLogMapper.java
@@ -0,0 +1,11 @@
+package com.vincent.rsf.server.ai.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.vincent.rsf.server.ai.entity.AiMcpCallLog;
+import org.apache.ibatis.annotations.Mapper;
+import org.springframework.stereotype.Repository;
+
+@Mapper
+@Repository
+public interface AiMcpCallLogMapper extends BaseMapper<AiMcpCallLog> {
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiCallLogService.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiCallLogService.java
new file mode 100644
index 0000000..0b3d078
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiCallLogService.java
@@ -0,0 +1,30 @@
+package com.vincent.rsf.server.ai.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.vincent.rsf.server.ai.dto.AiObserveStatsDto;
+import com.vincent.rsf.server.ai.entity.AiCallLog;
+import com.vincent.rsf.server.ai.entity.AiMcpCallLog;
+
+import java.util.List;
+
+public interface AiCallLogService extends IService<AiCallLog> {
+
+    AiCallLog startCallLog(String requestId, Long sessionId, Long userId, Long tenantId, String promptCode,
+                           String promptName, String model, Integer configuredMcpCount,
+                           Integer mountedMcpCount, List<String> mountedMcpNames);
+
+    void completeCallLog(Long callLogId, String status, Long elapsedMs, Long firstTokenLatencyMs,
+                         Integer promptTokens, Integer completionTokens, Integer totalTokens,
+                         long toolSuccessCount, long toolFailureCount);
+
+    void failCallLog(Long callLogId, String status, String errorCategory, String errorStage, String errorMessage,
+                     Long elapsedMs, Long firstTokenLatencyMs, long toolSuccessCount, long toolFailureCount);
+
+    void saveMcpCallLog(Long callLogId, String requestId, Long sessionId, String toolCallId, String mountName,
+                        String toolName, String status, String inputSummary, String outputSummary,
+                        String errorMessage, Long durationMs, Long userId, Long tenantId);
+
+    AiObserveStatsDto getObserveStats(Long tenantId);
+
+    List<AiMcpCallLog> listMcpLogs(Long callLogId, Long tenantId);
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/MountedToolCallback.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/MountedToolCallback.java
new file mode 100644
index 0000000..efc6f88
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/MountedToolCallback.java
@@ -0,0 +1,8 @@
+package com.vincent.rsf.server.ai.service;
+
+import org.springframework.ai.tool.ToolCallback;
+
+public interface MountedToolCallback extends ToolCallback {
+
+    String getMountName();
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiCallLogServiceImpl.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiCallLogServiceImpl.java
new file mode 100644
index 0000000..d3827a6
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiCallLogServiceImpl.java
@@ -0,0 +1,184 @@
+package com.vincent.rsf.server.ai.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.vincent.rsf.server.ai.dto.AiObserveStatsDto;
+import com.vincent.rsf.server.ai.entity.AiCallLog;
+import com.vincent.rsf.server.ai.entity.AiMcpCallLog;
+import com.vincent.rsf.server.ai.mapper.AiCallLogMapper;
+import com.vincent.rsf.server.ai.mapper.AiMcpCallLogMapper;
+import com.vincent.rsf.server.ai.service.AiCallLogService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Objects;
+import java.util.regex.Pattern;
+
+@Service("aiCallLogService")
+@RequiredArgsConstructor
+public class AiCallLogServiceImpl extends ServiceImpl<AiCallLogMapper, AiCallLog> implements AiCallLogService {
+
+    private static final Pattern SECRET_JSON_PATTERN = Pattern.compile("(?i)(\"(?:apiKey|token|accessToken|refreshToken|password|authorization)\"\\s*:\\s*\")([^\"]+)(\")");
+    private static final Pattern BEARER_PATTERN = Pattern.compile("(?i)(bearer\\s+)([a-z0-9._-]+)");
+
+    private final AiMcpCallLogMapper aiMcpCallLogMapper;
+
+    @Override
+    public AiCallLog startCallLog(String requestId, Long sessionId, Long userId, Long tenantId, String promptCode,
+                                  String promptName, String model, Integer configuredMcpCount,
+                                  Integer mountedMcpCount, List<String> mountedMcpNames) {
+        Date now = new Date();
+        AiCallLog callLog = new AiCallLog()
+                .setRequestId(requestId)
+                .setSessionId(sessionId)
+                .setUserId(userId)
+                .setTenantId(tenantId)
+                .setPromptCode(promptCode)
+                .setPromptName(promptName)
+                .setModel(model)
+                .setStatus("RUNNING")
+                .setConfiguredMcpCount(configuredMcpCount)
+                .setMountedMcpCount(mountedMcpCount)
+                .setMountedMcpNames(joinNames(mountedMcpNames))
+                .setToolCallCount(0)
+                .setToolSuccessCount(0)
+                .setToolFailureCount(0)
+                .setDeleted(0)
+                .setCreateTime(now)
+                .setUpdateTime(now);
+        this.save(callLog);
+        return callLog;
+    }
+
+    @Override
+    public void completeCallLog(Long callLogId, String status, Long elapsedMs, Long firstTokenLatencyMs,
+                                Integer promptTokens, Integer completionTokens, Integer totalTokens,
+                                long toolSuccessCount, long toolFailureCount) {
+        if (callLogId == null) {
+            return;
+        }
+        this.update(new LambdaUpdateWrapper<AiCallLog>()
+                .eq(AiCallLog::getId, callLogId)
+                .set(AiCallLog::getStatus, status)
+                .set(AiCallLog::getElapsedMs, elapsedMs)
+                .set(AiCallLog::getFirstTokenLatencyMs, firstTokenLatencyMs)
+                .set(AiCallLog::getPromptTokens, promptTokens)
+                .set(AiCallLog::getCompletionTokens, completionTokens)
+                .set(AiCallLog::getTotalTokens, totalTokens)
+                .set(AiCallLog::getToolSuccessCount, (int) toolSuccessCount)
+                .set(AiCallLog::getToolFailureCount, (int) toolFailureCount)
+                .set(AiCallLog::getToolCallCount, (int) (toolSuccessCount + toolFailureCount))
+                .set(AiCallLog::getUpdateTime, new Date()));
+    }
+
+    @Override
+    public void failCallLog(Long callLogId, String status, String errorCategory, String errorStage, String errorMessage,
+                            Long elapsedMs, Long firstTokenLatencyMs, long toolSuccessCount, long toolFailureCount) {
+        if (callLogId == null) {
+            return;
+        }
+        this.update(new LambdaUpdateWrapper<AiCallLog>()
+                .eq(AiCallLog::getId, callLogId)
+                .set(AiCallLog::getStatus, status)
+                .set(AiCallLog::getErrorCategory, errorCategory)
+                .set(AiCallLog::getErrorStage, errorStage)
+                .set(AiCallLog::getErrorMessage, maskSensitive(errorMessage))
+                .set(AiCallLog::getElapsedMs, elapsedMs)
+                .set(AiCallLog::getFirstTokenLatencyMs, firstTokenLatencyMs)
+                .set(AiCallLog::getToolSuccessCount, (int) toolSuccessCount)
+                .set(AiCallLog::getToolFailureCount, (int) toolFailureCount)
+                .set(AiCallLog::getToolCallCount, (int) (toolSuccessCount + toolFailureCount))
+                .set(AiCallLog::getUpdateTime, new Date()));
+    }
+
+    @Override
+    public void saveMcpCallLog(Long callLogId, String requestId, Long sessionId, String toolCallId, String mountName,
+                               String toolName, String status, String inputSummary, String outputSummary,
+                               String errorMessage, Long durationMs, Long userId, Long tenantId) {
+        if (callLogId == null) {
+            return;
+        }
+        aiMcpCallLogMapper.insert(new AiMcpCallLog()
+                .setCallLogId(callLogId)
+                .setRequestId(requestId)
+                .setSessionId(sessionId)
+                .setToolCallId(toolCallId)
+                .setMountName(mountName)
+                .setToolName(toolName)
+                .setStatus(status)
+                .setInputSummary(maskSensitive(inputSummary))
+                .setOutputSummary(maskSensitive(outputSummary))
+                .setErrorMessage(maskSensitive(errorMessage))
+                .setDurationMs(durationMs)
+                .setUserId(userId)
+                .setTenantId(tenantId)
+                .setCreateTime(new Date()));
+    }
+
+    @Override
+    public AiObserveStatsDto getObserveStats(Long tenantId) {
+        List<AiCallLog> callLogs = this.list(new LambdaQueryWrapper<AiCallLog>()
+                .eq(AiCallLog::getTenantId, tenantId)
+                .eq(AiCallLog::getDeleted, 0)
+                .orderByDesc(AiCallLog::getId));
+        List<AiMcpCallLog> mcpCallLogs = aiMcpCallLogMapper.selectList(new LambdaQueryWrapper<AiMcpCallLog>()
+                .eq(AiMcpCallLog::getTenantId, tenantId)
+                .orderByDesc(AiMcpCallLog::getId));
+
+        long callCount = callLogs.size();
+        long successCount = callLogs.stream().filter(item -> "COMPLETED".equals(item.getStatus())).count();
+        long failureCount = callLogs.stream().filter(item -> "FAILED".equals(item.getStatus())).count();
+        long totalElapsed = callLogs.stream().map(AiCallLog::getElapsedMs).filter(Objects::nonNull).mapToLong(Long::longValue).sum();
+        long elapsedCount = callLogs.stream().map(AiCallLog::getElapsedMs).filter(Objects::nonNull).count();
+        long totalFirstToken = callLogs.stream().map(AiCallLog::getFirstTokenLatencyMs).filter(Objects::nonNull).mapToLong(Long::longValue).sum();
+        long firstTokenCount = callLogs.stream().map(AiCallLog::getFirstTokenLatencyMs).filter(Objects::nonNull).count();
+        long totalTokens = callLogs.stream().map(AiCallLog::getTotalTokens).filter(Objects::nonNull).mapToLong(Integer::longValue).sum();
+        long tokenCount = callLogs.stream().map(AiCallLog::getTotalTokens).filter(Objects::nonNull).count();
+
+        long toolCallCount = mcpCallLogs.size();
+        long toolSuccessCount = mcpCallLogs.stream().filter(item -> "COMPLETED".equals(item.getStatus())).count();
+        long toolFailureCount = mcpCallLogs.stream().filter(item -> "FAILED".equals(item.getStatus())).count();
+        double toolSuccessRate = toolCallCount == 0 ? 0D : (toolSuccessCount * 100D) / toolCallCount;
+
+        return AiObserveStatsDto.builder()
+                .callCount(callCount)
+                .successCount(successCount)
+                .failureCount(failureCount)
+                .avgElapsedMs(elapsedCount == 0 ? 0L : totalElapsed / elapsedCount)
+                .avgFirstTokenLatencyMs(firstTokenCount == 0 ? 0L : totalFirstToken / firstTokenCount)
+                .totalTokens(totalTokens)
+                .avgTotalTokens(tokenCount == 0 ? 0L : totalTokens / tokenCount)
+                .toolCallCount(toolCallCount)
+                .toolSuccessCount(toolSuccessCount)
+                .toolFailureCount(toolFailureCount)
+                .toolSuccessRate(toolSuccessRate)
+                .build();
+    }
+
+    @Override
+    public List<AiMcpCallLog> listMcpLogs(Long callLogId, Long tenantId) {
+        return aiMcpCallLogMapper.selectList(new LambdaQueryWrapper<AiMcpCallLog>()
+                .eq(AiMcpCallLog::getCallLogId, callLogId)
+                .eq(AiMcpCallLog::getTenantId, tenantId)
+                .orderByDesc(AiMcpCallLog::getId));
+    }
+
+    private String joinNames(List<String> names) {
+        if (names == null || names.isEmpty()) {
+            return "";
+        }
+        return String.join("銆�", names);
+    }
+
+    private String maskSensitive(String source) {
+        if (!StringUtils.hasText(source)) {
+            return source;
+        }
+        String masked = SECRET_JSON_PATTERN.matcher(source).replaceAll("$1***$3");
+        return BEARER_PATTERN.matcher(masked).replaceAll("$1***");
+    }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java
index 5e3c7ab..8a784ea 100644
--- a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java
@@ -16,14 +16,17 @@
 import com.vincent.rsf.server.ai.dto.AiChatSessionRenameRequest;
 import com.vincent.rsf.server.ai.dto.AiChatToolEventDto;
 import com.vincent.rsf.server.ai.dto.AiResolvedConfig;
+import com.vincent.rsf.server.ai.entity.AiCallLog;
 import com.vincent.rsf.server.ai.entity.AiParam;
 import com.vincent.rsf.server.ai.entity.AiPrompt;
 import com.vincent.rsf.server.ai.entity.AiChatSession;
 import com.vincent.rsf.server.ai.enums.AiErrorCategory;
 import com.vincent.rsf.server.ai.exception.AiChatException;
+import com.vincent.rsf.server.ai.service.AiCallLogService;
 import com.vincent.rsf.server.ai.service.AiChatService;
 import com.vincent.rsf.server.ai.service.AiChatMemoryService;
 import com.vincent.rsf.server.ai.service.AiConfigResolverService;
+import com.vincent.rsf.server.ai.service.MountedToolCallback;
 import com.vincent.rsf.server.ai.service.McpMountRuntimeFactory;
 import io.micrometer.observation.ObservationRegistry;
 import lombok.RequiredArgsConstructor;
@@ -78,6 +81,7 @@
     private final AiConfigResolverService aiConfigResolverService;
     private final AiChatMemoryService aiChatMemoryService;
     private final McpMountRuntimeFactory mcpMountRuntimeFactory;
+    private final AiCallLogService aiCallLogService;
     private final GenericApplicationContext applicationContext;
     private final ObservationRegistry observationRegistry;
     private final ObjectMapper objectMapper;
@@ -148,7 +152,10 @@
         long startedAt = System.currentTimeMillis();
         AtomicReference<Long> firstTokenAtRef = new AtomicReference<>();
         AtomicLong toolCallSequence = new AtomicLong(0);
+        AtomicLong toolSuccessCount = new AtomicLong(0);
+        AtomicLong toolFailureCount = new AtomicLong(0);
         Long sessionId = request.getSessionId();
+        Long callLogId = null;
         String model = null;
         try {
             ensureIdentity(userId, tenantId);
@@ -159,6 +166,19 @@
             sessionId = session.getId();
             AiChatMemoryDto memory = loadMemory(userId, tenantId, config.getPromptCode(), session.getId());
             List<AiChatMessageDto> mergedMessages = mergeMessages(memory.getShortMemoryMessages(), request.getMessages());
+            AiCallLog callLog = aiCallLogService.startCallLog(
+                    requestId,
+                    session.getId(),
+                    userId,
+                    tenantId,
+                    config.getPromptCode(),
+                    config.getPrompt().getName(),
+                    config.getAiParam().getModel(),
+                    config.getMcpMounts().size(),
+                    config.getMcpMounts().size(),
+                    config.getMcpMounts().stream().map(item -> item.getName()).toList()
+            );
+            callLogId = callLog.getId();
             try (McpMountRuntimeFactory.McpMountRuntime runtime = createRuntime(config, userId)) {
                 emitStrict(emitter, "start", AiChatRuntimeDto.builder()
                         .requestId(requestId)
@@ -187,7 +207,8 @@
                         requestId, userId, tenantId, session.getId(), resolvedModel);
 
                 ToolCallback[] observableToolCallbacks = wrapToolCallbacks(
-                        runtime.getToolCallbacks(), emitter, requestId, session.getId(), toolCallSequence
+                        runtime.getToolCallbacks(), emitter, requestId, session.getId(), toolCallSequence,
+                        toolSuccessCount, toolFailureCount, callLogId, userId, tenantId
                 );
                 Prompt prompt = new Prompt(
                         buildPromptMessages(memory, mergedMessages, config.getPrompt(), request.getMetadata()),
@@ -205,6 +226,17 @@
                     }
                     emitDone(emitter, requestId, response.getMetadata(), config.getAiParam().getModel(), session.getId(), startedAt, firstTokenAtRef.get());
                     emitSafely(emitter, "status", buildTerminalStatus(requestId, session.getId(), "COMPLETED", resolvedModel, startedAt, firstTokenAtRef.get()));
+                    aiCallLogService.completeCallLog(
+                            callLogId,
+                            "COMPLETED",
+                            System.currentTimeMillis() - startedAt,
+                            resolveFirstTokenLatency(startedAt, firstTokenAtRef.get()),
+                            response.getMetadata() == null || response.getMetadata().getUsage() == null ? null : response.getMetadata().getUsage().getPromptTokens(),
+                            response.getMetadata() == null || response.getMetadata().getUsage() == null ? null : response.getMetadata().getUsage().getCompletionTokens(),
+                            response.getMetadata() == null || response.getMetadata().getUsage() == null ? null : response.getMetadata().getUsage().getTotalTokens(),
+                            toolSuccessCount.get(),
+                            toolFailureCount.get()
+                    );
                     log.info("AI chat completed, requestId={}, sessionId={}, elapsedMs={}, firstTokenLatencyMs={}",
                             requestId, session.getId(), System.currentTimeMillis() - startedAt, resolveFirstTokenLatency(startedAt, firstTokenAtRef.get()));
                     emitter.complete();
@@ -232,16 +264,29 @@
                 aiChatMemoryService.saveRound(session, userId, tenantId, request.getMessages(), assistantContent.toString());
                 emitDone(emitter, requestId, lastMetadata.get(), config.getAiParam().getModel(), session.getId(), startedAt, firstTokenAtRef.get());
                 emitSafely(emitter, "status", buildTerminalStatus(requestId, session.getId(), "COMPLETED", resolvedModel, startedAt, firstTokenAtRef.get()));
+                aiCallLogService.completeCallLog(
+                        callLogId,
+                        "COMPLETED",
+                        System.currentTimeMillis() - startedAt,
+                        resolveFirstTokenLatency(startedAt, firstTokenAtRef.get()),
+                        lastMetadata.get() == null || lastMetadata.get().getUsage() == null ? null : lastMetadata.get().getUsage().getPromptTokens(),
+                        lastMetadata.get() == null || lastMetadata.get().getUsage() == null ? null : lastMetadata.get().getUsage().getCompletionTokens(),
+                        lastMetadata.get() == null || lastMetadata.get().getUsage() == null ? null : lastMetadata.get().getUsage().getTotalTokens(),
+                        toolSuccessCount.get(),
+                        toolFailureCount.get()
+                );
                 log.info("AI chat completed, requestId={}, sessionId={}, elapsedMs={}, firstTokenLatencyMs={}",
                         requestId, session.getId(), System.currentTimeMillis() - startedAt, resolveFirstTokenLatency(startedAt, firstTokenAtRef.get()));
                 emitter.complete();
             }
         } catch (AiChatException e) {
-            handleStreamFailure(emitter, requestId, sessionId, model, startedAt, firstTokenAtRef.get(), e);
+            handleStreamFailure(emitter, requestId, sessionId, model, startedAt, firstTokenAtRef.get(), e,
+                    callLogId, toolSuccessCount.get(), toolFailureCount.get());
         } catch (Exception e) {
             handleStreamFailure(emitter, requestId, sessionId, model, startedAt, firstTokenAtRef.get(),
                     buildAiException("AI_INTERNAL_ERROR", AiErrorCategory.INTERNAL, "INTERNAL",
-                            e == null ? "AI 瀵硅瘽澶辫触" : e.getMessage(), e));
+                            e == null ? "AI 瀵硅瘽澶辫触" : e.getMessage(), e),
+                    callLogId, toolSuccessCount.get(), toolFailureCount.get());
         } finally {
             log.debug("AI chat stream finished, requestId={}", requestId);
         }
@@ -341,11 +386,24 @@
         return firstTokenAt == null ? null : Math.max(0L, firstTokenAt - startedAt);
     }
 
-    private void handleStreamFailure(SseEmitter emitter, String requestId, Long sessionId, String model, long startedAt, Long firstTokenAt, AiChatException exception) {
+    private void handleStreamFailure(SseEmitter emitter, String requestId, Long sessionId, String model, long startedAt,
+                                     Long firstTokenAt, AiChatException exception, Long callLogId,
+                                     long toolSuccessCount, long toolFailureCount) {
         if (isClientAbortException(exception)) {
             log.warn("AI chat aborted by client, requestId={}, sessionId={}, stage={}, message={}",
                     requestId, sessionId, exception.getStage(), exception.getMessage());
             emitSafely(emitter, "status", buildTerminalStatus(requestId, sessionId, "ABORTED", model, startedAt, firstTokenAt));
+            aiCallLogService.failCallLog(
+                    callLogId,
+                    "ABORTED",
+                    exception.getCategory().name(),
+                    exception.getStage(),
+                    exception.getMessage(),
+                    System.currentTimeMillis() - startedAt,
+                    resolveFirstTokenLatency(startedAt, firstTokenAt),
+                    toolSuccessCount,
+                    toolFailureCount
+            );
             emitter.completeWithError(exception);
             return;
         }
@@ -361,6 +419,17 @@
                 .message(exception.getMessage())
                 .timestamp(Instant.now().toEpochMilli())
                 .build());
+        aiCallLogService.failCallLog(
+                callLogId,
+                "FAILED",
+                exception.getCategory().name(),
+                exception.getStage(),
+                exception.getMessage(),
+                System.currentTimeMillis() - startedAt,
+                resolveFirstTokenLatency(startedAt, firstTokenAt),
+                toolSuccessCount,
+                toolFailureCount
+        );
         emitter.completeWithError(exception);
     }
 
@@ -436,7 +505,9 @@
     }
 
     private ToolCallback[] wrapToolCallbacks(ToolCallback[] toolCallbacks, SseEmitter emitter, String requestId,
-                                             Long sessionId, AtomicLong toolCallSequence) {
+                                             Long sessionId, AtomicLong toolCallSequence,
+                                             AtomicLong toolSuccessCount, AtomicLong toolFailureCount,
+                                             Long callLogId, Long userId, Long tenantId) {
         if (Cools.isEmpty(toolCallbacks)) {
             return toolCallbacks;
         }
@@ -445,7 +516,8 @@
             if (callback == null) {
                 continue;
             }
-            wrappedCallbacks.add(new ObservableToolCallback(callback, emitter, requestId, sessionId, toolCallSequence));
+            wrappedCallbacks.add(new ObservableToolCallback(callback, emitter, requestId, sessionId, toolCallSequence,
+                    toolSuccessCount, toolFailureCount, callLogId, userId, tenantId));
         }
         return wrappedCallbacks.toArray(new ToolCallback[0]);
     }
@@ -637,14 +709,26 @@
         private final String requestId;
         private final Long sessionId;
         private final AtomicLong toolCallSequence;
+        private final AtomicLong toolSuccessCount;
+        private final AtomicLong toolFailureCount;
+        private final Long callLogId;
+        private final Long userId;
+        private final Long tenantId;
 
         private ObservableToolCallback(ToolCallback delegate, SseEmitter emitter, String requestId,
-                                       Long sessionId, AtomicLong toolCallSequence) {
+                                       Long sessionId, AtomicLong toolCallSequence,
+                                       AtomicLong toolSuccessCount, AtomicLong toolFailureCount,
+                                       Long callLogId, Long userId, Long tenantId) {
             this.delegate = delegate;
             this.emitter = emitter;
             this.requestId = requestId;
             this.sessionId = sessionId;
             this.toolCallSequence = toolCallSequence;
+            this.toolSuccessCount = toolSuccessCount;
+            this.toolFailureCount = toolFailureCount;
+            this.callLogId = callLogId;
+            this.userId = userId;
+            this.tenantId = tenantId;
         }
 
         @Override
@@ -665,6 +749,7 @@
         @Override
         public String call(String toolInput, ToolContext toolContext) {
             String toolName = delegate.getToolDefinition() == null ? "unknown" : delegate.getToolDefinition().name();
+            String mountName = delegate instanceof MountedToolCallback ? ((MountedToolCallback) delegate).getMountName() : null;
             String toolCallId = requestId + "-tool-" + toolCallSequence.incrementAndGet();
             long startedAt = System.currentTimeMillis();
             emitSafely(emitter, "tool_start", AiChatToolEventDto.builder()
@@ -672,36 +757,49 @@
                     .sessionId(sessionId)
                     .toolCallId(toolCallId)
                     .toolName(toolName)
+                    .mountName(mountName)
                     .status("STARTED")
                     .inputSummary(summarizeToolPayload(toolInput, 400))
                     .timestamp(startedAt)
                     .build());
             try {
                 String output = toolContext == null ? delegate.call(toolInput) : delegate.call(toolInput, toolContext);
+                long durationMs = System.currentTimeMillis() - startedAt;
                 emitSafely(emitter, "tool_result", AiChatToolEventDto.builder()
                         .requestId(requestId)
                         .sessionId(sessionId)
                         .toolCallId(toolCallId)
                         .toolName(toolName)
+                        .mountName(mountName)
                         .status("COMPLETED")
                         .inputSummary(summarizeToolPayload(toolInput, 400))
                         .outputSummary(summarizeToolPayload(output, 600))
-                        .durationMs(System.currentTimeMillis() - startedAt)
+                        .durationMs(durationMs)
                         .timestamp(System.currentTimeMillis())
                         .build());
+                toolSuccessCount.incrementAndGet();
+                aiCallLogService.saveMcpCallLog(callLogId, requestId, sessionId, toolCallId, mountName, toolName,
+                        "COMPLETED", summarizeToolPayload(toolInput, 400), summarizeToolPayload(output, 600),
+                        null, durationMs, userId, tenantId);
                 return output;
             } catch (RuntimeException e) {
+                long durationMs = System.currentTimeMillis() - startedAt;
                 emitSafely(emitter, "tool_error", AiChatToolEventDto.builder()
                         .requestId(requestId)
                         .sessionId(sessionId)
                         .toolCallId(toolCallId)
                         .toolName(toolName)
+                        .mountName(mountName)
                         .status("FAILED")
                         .inputSummary(summarizeToolPayload(toolInput, 400))
                         .errorMessage(e.getMessage())
-                        .durationMs(System.currentTimeMillis() - startedAt)
+                        .durationMs(durationMs)
                         .timestamp(System.currentTimeMillis())
                         .build());
+                toolFailureCount.incrementAndGet();
+                aiCallLogService.saveMcpCallLog(callLogId, requestId, sessionId, toolCallId, mountName, toolName,
+                        "FAILED", summarizeToolPayload(toolInput, 400), null, e.getMessage(),
+                        durationMs, userId, tenantId);
                 throw e;
             }
         }
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/McpMountRuntimeFactoryImpl.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/McpMountRuntimeFactoryImpl.java
index 6b36785..a290fd7 100644
--- a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/McpMountRuntimeFactoryImpl.java
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/McpMountRuntimeFactoryImpl.java
@@ -6,6 +6,7 @@
 import com.vincent.rsf.server.ai.config.AiDefaults;
 import com.vincent.rsf.server.ai.entity.AiMcpMount;
 import com.vincent.rsf.server.ai.service.BuiltinMcpToolRegistry;
+import com.vincent.rsf.server.ai.service.MountedToolCallback;
 import com.vincent.rsf.server.ai.service.McpMountRuntimeFactory;
 import io.modelcontextprotocol.client.McpClient;
 import io.modelcontextprotocol.client.McpSyncClient;
@@ -47,7 +48,10 @@
         for (AiMcpMount mount : mounts) {
             try {
                 if (AiDefaults.MCP_TRANSPORT_BUILTIN.equals(mount.getTransportType())) {
-                    callbacks.addAll(builtinMcpToolRegistry.createToolCallbacks(mount, userId));
+                    callbacks.addAll(wrapMountedCallbacks(
+                            builtinMcpToolRegistry.createToolCallbacks(mount, userId),
+                            mount.getName()
+                    ));
                     mountedNames.add(mount.getName());
                     continue;
                 }
@@ -55,6 +59,10 @@
                 client.initialize();
                 client.listTools();
                 clients.add(client);
+                callbacks.addAll(wrapMountedCallbacks(
+                        Arrays.asList(SyncMcpToolCallbackProvider.builder().mcpClients(List.of(client)).build().getToolCallbacks()),
+                        mount.getName()
+                ));
                 mountedNames.add(mount.getName());
             } catch (Exception e) {
                 String message = mount.getName() + " 鎸傝浇澶辫触: " + e.getMessage();
@@ -62,13 +70,19 @@
                 log.warn(message, e);
             }
         }
-        if (!clients.isEmpty()) {
-            callbacks.addAll(Arrays.asList(
-                    SyncMcpToolCallbackProvider.builder().mcpClients(clients).build().getToolCallbacks()
-            ));
-        }
         ensureUniqueToolNames(callbacks);
         return new DefaultMcpMountRuntime(clients, callbacks.toArray(new ToolCallback[0]), mountedNames, errors);
+    }
+
+    private List<ToolCallback> wrapMountedCallbacks(List<ToolCallback> source, String mountName) {
+        List<ToolCallback> mountedCallbacks = new ArrayList<>();
+        for (ToolCallback callback : source) {
+            if (callback == null) {
+                continue;
+            }
+            mountedCallbacks.add(new MountedToolCallbackImpl(callback, mountName));
+        }
+        return mountedCallbacks;
     }
 
     private void ensureUniqueToolNames(List<ToolCallback> callbacks) {
@@ -208,4 +222,40 @@
             }
         }
     }
+
+    private static class MountedToolCallbackImpl implements MountedToolCallback {
+
+        private final ToolCallback delegate;
+        private final String mountName;
+
+        private MountedToolCallbackImpl(ToolCallback delegate, String mountName) {
+            this.delegate = delegate;
+            this.mountName = mountName;
+        }
+
+        @Override
+        public String getMountName() {
+            return mountName;
+        }
+
+        @Override
+        public org.springframework.ai.tool.definition.ToolDefinition getToolDefinition() {
+            return delegate.getToolDefinition();
+        }
+
+        @Override
+        public org.springframework.ai.tool.metadata.ToolMetadata getToolMetadata() {
+            return delegate.getToolMetadata();
+        }
+
+        @Override
+        public String call(String toolInput) {
+            return delegate.call(toolInput);
+        }
+
+        @Override
+        public String call(String toolInput, org.springframework.ai.chat.model.ToolContext toolContext) {
+            return delegate.call(toolInput, toolContext);
+        }
+    }
 }
diff --git a/version/db/ai_feature.sql b/version/db/ai_feature.sql
index 8ca9631..ab0819b 100644
--- a/version/db/ai_feature.sql
+++ b/version/db/ai_feature.sql
@@ -113,6 +113,59 @@
   KEY `idx_sys_ai_chat_message_session_seq` (`session_id`,`seq_no`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 瀵硅瘽娑堟伅';
 
+CREATE TABLE IF NOT EXISTS `sys_ai_call_log` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
+  `request_id` varchar(128) NOT NULL COMMENT '璇锋眰ID',
+  `session_id` bigint(20) DEFAULT NULL COMMENT '浼氳瘽ID',
+  `prompt_code` varchar(128) DEFAULT NULL COMMENT 'Prompt缂栫爜',
+  `prompt_name` varchar(255) DEFAULT NULL COMMENT 'Prompt鍚嶇О',
+  `model` varchar(255) DEFAULT NULL COMMENT '妯″瀷',
+  `user_id` bigint(20) DEFAULT NULL COMMENT '鐢ㄦ埛ID',
+  `tenant_id` bigint(20) DEFAULT NULL COMMENT '绉熸埛ID',
+  `status` varchar(32) DEFAULT NULL COMMENT '鐘舵��',
+  `error_category` varchar(64) DEFAULT NULL COMMENT '閿欒鍒嗙被',
+  `error_stage` varchar(64) DEFAULT NULL COMMENT '閿欒闃舵',
+  `error_message` varchar(1000) DEFAULT NULL COMMENT '閿欒淇℃伅',
+  `configured_mcp_count` int(11) DEFAULT NULL COMMENT '閰嶇疆MCP鏁伴噺',
+  `mounted_mcp_count` int(11) DEFAULT NULL COMMENT '鎸傝浇MCP鏁伴噺',
+  `mounted_mcp_names` varchar(1000) DEFAULT NULL COMMENT '鎸傝浇MCP鍚嶇О',
+  `tool_call_count` int(11) DEFAULT NULL COMMENT '宸ュ叿璋冪敤鎬绘暟',
+  `tool_success_count` int(11) DEFAULT NULL COMMENT '宸ュ叿鎴愬姛鏁�',
+  `tool_failure_count` int(11) DEFAULT NULL COMMENT '宸ュ叿澶辫触鏁�',
+  `elapsed_ms` bigint(20) DEFAULT NULL COMMENT '鎬昏�楁椂',
+  `first_token_latency_ms` bigint(20) DEFAULT NULL COMMENT '棣栧寘鑰楁椂',
+  `prompt_tokens` int(11) DEFAULT NULL COMMENT 'Prompt Tokens',
+  `completion_tokens` int(11) DEFAULT NULL COMMENT 'Completion Tokens',
+  `total_tokens` int(11) DEFAULT NULL COMMENT 'Total Tokens',
+  `deleted` int(11) DEFAULT '0' COMMENT '鍒犻櫎鏍囪',
+  `create_time` datetime DEFAULT NULL COMMENT '鍒涘缓鏃堕棿',
+  `update_time` datetime DEFAULT NULL COMMENT '鏇存柊鏃堕棿',
+  PRIMARY KEY (`id`),
+  KEY `idx_sys_ai_call_log_tenant_create` (`tenant_id`,`create_time`),
+  KEY `idx_sys_ai_call_log_request` (`request_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 璋冪敤鏃ュ織';
+
+CREATE TABLE IF NOT EXISTS `sys_ai_mcp_call_log` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
+  `call_log_id` bigint(20) NOT NULL COMMENT 'AI璋冪敤鏃ュ織ID',
+  `request_id` varchar(128) DEFAULT NULL COMMENT '璇锋眰ID',
+  `session_id` bigint(20) DEFAULT NULL COMMENT '浼氳瘽ID',
+  `tool_call_id` varchar(128) DEFAULT NULL COMMENT '宸ュ叿璋冪敤ID',
+  `mount_name` varchar(255) DEFAULT NULL COMMENT '鎸傝浇鍚嶇О',
+  `tool_name` varchar(255) DEFAULT NULL COMMENT '宸ュ叿鍚嶇О',
+  `status` varchar(32) DEFAULT NULL COMMENT '鐘舵��',
+  `input_summary` text COMMENT '杈撳叆鎽樿',
+  `output_summary` text COMMENT '杈撳嚭鎽樿',
+  `error_message` varchar(1000) DEFAULT NULL COMMENT '閿欒淇℃伅',
+  `duration_ms` bigint(20) DEFAULT NULL COMMENT '鑰楁椂',
+  `user_id` bigint(20) DEFAULT NULL COMMENT '鐢ㄦ埛ID',
+  `tenant_id` bigint(20) DEFAULT NULL COMMENT '绉熸埛ID',
+  `create_time` datetime DEFAULT NULL COMMENT '鍒涘缓鏃堕棿',
+  PRIMARY KEY (`id`),
+  KEY `idx_sys_ai_mcp_call_log_call` (`call_log_id`,`create_time`),
+  KEY `idx_sys_ai_mcp_call_log_tenant` (`tenant_id`,`create_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI MCP 璋冪敤鏃ュ織';
+
 SET @ai_param_validate_status_exists := (
   SELECT COUNT(1)
   FROM `information_schema`.`COLUMNS`
@@ -394,7 +447,9 @@
 (5312, 'Query AI MCP Mount', 5311, NULL, '1,5311', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiMcpMount:list', NULL, 0, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL),
 (5313, 'Create AI MCP Mount', 5311, NULL, '1,5311', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiMcpMount:save', NULL, 1, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL),
 (5314, 'Update AI MCP Mount', 5311, NULL, '1,5311', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiMcpMount:update', NULL, 2, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL),
-(5315, 'Delete AI MCP Mount', 5311, NULL, '1,5311', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiMcpMount:remove', NULL, 3, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL)
+(5315, 'Delete AI MCP Mount', 5311, NULL, '1,5311', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiMcpMount:remove', NULL, 3, NULL, 1, 1, 0, '2026-03-18 19:00:00', 2, '2026-03-18 19:00:00', 2, NULL),
+(5316, 'menu.aiCallLog', 1, 'menu.system', '1', 'menu.system', '/system/aiCallLog', 'aiCallLog', NULL, NULL, 0, NULL, 'QueryStats', 14, NULL, 1, 1, 0, '2026-03-19 13:00:00', 2, '2026-03-19 13:00:00', 2, NULL),
+(5317, 'Query AI Call Log', 5316, NULL, '1,5316', NULL, NULL, NULL, NULL, NULL, 1, 'system:aiCallLog:list', NULL, 0, NULL, 1, 1, 0, '2026-03-19 13:00:00', 2, '2026-03-19 13:00:00', 2, NULL)
 ON DUPLICATE KEY UPDATE
 `name` = VALUES(`name`),
 `parent_id` = VALUES(`parent_id`),
@@ -428,7 +483,9 @@
 (5312, 1, 5312),
 (5313, 1, 5313),
 (5314, 1, 5314),
-(5315, 1, 5315)
+(5315, 1, 5315),
+(5316, 1, 5316),
+(5317, 1, 5317)
 ON DUPLICATE KEY UPDATE
 `role_id` = VALUES(`role_id`),
 `menu_id` = VALUES(`menu_id`);

--
Gitblit v1.9.1