| New file |
| | |
| | | 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 调用日志失败"); |
| | | }; |
| | |
| | | const AI_COMPONENTS = new Set([ |
| | | 'aiDiagnosis', |
| | | 'aiDiagnosisPlan', |
| | | 'aiCallLog', |
| | | 'aiRoute', |
| | | 'aiToolConfig', |
| | | ]); |
| | |
| | | aiParam: 'AI Params', |
| | | aiPrompt: 'Prompts', |
| | | aiMcpMount: 'MCP Mounts', |
| | | aiCallLog: 'AI Observe', |
| | | tenant: 'Tenant', |
| | | userLogin: 'Token', |
| | | customer: 'Customer', |
| | |
| | | aiParam: 'AI 参数', |
| | | aiPrompt: 'Prompt 管理', |
| | | aiMcpMount: 'MCP 挂载', |
| | | aiCallLog: 'AI 观测', |
| | | tenant: '租户管理', |
| | | userLogin: '登录日志', |
| | | customer: '客户表', |
| | |
| | | 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) { |
| | |
| | | return aiPrompt; |
| | | case "aiMcpMount": |
| | | return aiMcpMount; |
| | | case "aiCallLog": |
| | | return aiCallLog; |
| | | // case "locItem": |
| | | // return locItem; |
| | | default: |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | import AiCallLogList from "./AiCallLogList"; |
| | | |
| | | export default { |
| | | list: AiCallLogList, |
| | | recordRepresentation: (record) => `${record?.requestId || ""}`, |
| | | }; |
| New file |
| | |
| | | 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())); |
| | | } |
| | | } |
| | |
| | | |
| | | private String toolName; |
| | | |
| | | private String mountName; |
| | | |
| | | private String status; |
| | | |
| | | private String inputSummary; |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.service; |
| | | |
| | | import org.springframework.ai.tool.ToolCallback; |
| | | |
| | | public interface MountedToolCallback extends ToolCallback { |
| | | |
| | | String getMountName(); |
| | | } |
| New file |
| | |
| | | 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***"); |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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); |
| | |
| | | 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) |
| | |
| | | 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()), |
| | |
| | | } |
| | | 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(); |
| | |
| | | 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); |
| | | } |
| | |
| | | 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; |
| | | } |
| | |
| | | .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); |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | 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; |
| | | } |
| | |
| | | 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]); |
| | | } |
| | |
| | | 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 |
| | |
| | | @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() |
| | |
| | | .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; |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | 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; |
| | | } |
| | |
| | | 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(); |
| | |
| | | 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) { |
| | |
| | | } |
| | | } |
| | | } |
| | | |
| | | 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); |
| | | } |
| | | } |
| | | } |
| | |
| | | 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` |
| | |
| | | (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`), |
| | |
| | | (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`); |