zhou zhou
15 小时以前 6477d7156272a6f1fe126c781958369bb10970c6
#ai 思维链
1个文件已添加
4个文件已修改
379 ■■■■■ 已修改文件
rsf-admin/src/i18n/en.js 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/i18n/zh.js 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/layout/AiChatDrawer.jsx 166 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatThinkingEventDto.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java 170 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-admin/src/i18n/en.js
@@ -508,6 +508,15 @@
            renameAction: "Rename session",
            deleteAction: "Delete session",
            toolTrace: "Tool Trace",
            thinkingProcess: "Thinking Process",
            thinkingExpand: "Show Thinking Process",
            thinkingCollapse: "Hide Thinking Process",
            thinkingEmpty: "Organizing the current stage information...",
            thinkingStatusStarted: "Started",
            thinkingStatusUpdated: "In Progress",
            thinkingStatusCompleted: "Completed",
            thinkingStatusFailed: "Failed",
            thinkingStatusAborted: "Aborted",
            noToolTrace: "No tool call was triggered in this round",
            unknownTool: "Unknown tool",
            toolStatusFailed: "Failed",
rsf-admin/src/i18n/zh.js
@@ -524,6 +524,15 @@
            renameAction: "重命名会话",
            deleteAction: "删除会话",
            toolTrace: "工具调用轨迹",
            thinkingProcess: "思考过程",
            thinkingExpand: "展开思考过程",
            thinkingCollapse: "收起思考过程",
            thinkingEmpty: "正在整理当前阶段信息...",
            thinkingStatusStarted: "已开始",
            thinkingStatusUpdated: "进行中",
            thinkingStatusCompleted: "已完成",
            thinkingStatusFailed: "失败",
            thinkingStatusAborted: "已中止",
            noToolTrace: "当前轮未触发工具调用",
            unknownTool: "未知工具",
            toolStatusFailed: "失败",
rsf-admin/src/layout/AiChatDrawer.jsx
@@ -42,6 +42,11 @@
import { clearAiSessionMemory, getAiRuntime, getAiSessions, pinAiSession, removeAiSession, renameAiSession, retainAiSessionLatestRound, streamAiChat } from "@/api/ai/chat";
const DEFAULT_PROMPT_CODE = "home.default";
const THINKING_PHASE_ORDER = {
    ANALYZE: 0,
    TOOL_CALL: 1,
    ANSWER: 2,
};
const AiChatDrawer = ({ open, onClose }) => {
    const navigate = useNavigate();
@@ -58,6 +63,8 @@
    const [messages, setMessages] = useState([]);
    const [toolEvents, setToolEvents] = useState([]);
    const [expandedToolIds, setExpandedToolIds] = useState([]);
    const [thinkingEvents, setThinkingEvents] = useState([]);
    const [thinkingExpanded, setThinkingExpanded] = useState(true);
    const [input, setInput] = useState("");
    const [loadingRuntime, setLoadingRuntime] = useState(false);
    const [streaming, setStreaming] = useState(false);
@@ -86,6 +93,18 @@
        };
    }, [runtime]);
    const currentThinkingMessageIndex = useMemo(() => {
        if (!thinkingEvents.length || !messages.length) {
            return -1;
        }
        for (let i = messages.length - 1; i >= 0; i -= 1) {
            if (messages[i]?.role === "assistant") {
                return i;
            }
        }
        return -1;
    }, [messages, thinkingEvents]);
    useEffect(() => {
        if (open) {
            initializeDrawer();
@@ -111,6 +130,8 @@
    const initializeDrawer = async (targetSessionId = null) => {
        setToolEvents([]);
        setExpandedToolIds([]);
        setThinkingEvents([]);
        setThinkingExpanded(true);
        await Promise.all([
            loadRuntime(targetSessionId),
            loadSessions(sessionKeyword),
@@ -154,6 +175,8 @@
        setMessages([]);
        setToolEvents([]);
        setExpandedToolIds([]);
        setThinkingEvents([]);
        setThinkingExpanded(true);
        setUsage(null);
        setDrawerError("");
    };
@@ -175,6 +198,8 @@
        setUsage(null);
        setToolEvents([]);
        setExpandedToolIds([]);
        setThinkingEvents([]);
        setThinkingExpanded(true);
        await loadRuntime(targetSessionId);
    };
@@ -348,6 +373,44 @@
        ));
    };
    const upsertThinkingEvent = (payload) => {
        if (!payload?.phase) {
            return;
        }
        setThinkingEvents((prev) => {
            const index = prev.findIndex((item) => item.phase === payload.phase);
            if (index < 0) {
                return [...prev, payload].sort((left, right) => (
                    (THINKING_PHASE_ORDER[left.phase] ?? Number.MAX_SAFE_INTEGER)
                    - (THINKING_PHASE_ORDER[right.phase] ?? Number.MAX_SAFE_INTEGER)
                ));
            }
            const next = [...prev];
            next[index] = { ...next[index], ...payload };
            return next;
        });
    };
    const toggleThinkingExpanded = () => {
        setThinkingExpanded((prev) => !prev);
    };
    const getThinkingStatusLabel = (status) => {
        if (status === "COMPLETED") {
            return translate("ai.drawer.thinkingStatusCompleted");
        }
        if (status === "FAILED") {
            return translate("ai.drawer.thinkingStatusFailed");
        }
        if (status === "ABORTED") {
            return translate("ai.drawer.thinkingStatusAborted");
        }
        if (status === "UPDATED") {
            return translate("ai.drawer.thinkingStatusUpdated");
        }
        return translate("ai.drawer.thinkingStatusStarted");
    };
    const handleSend = async () => {
        const content = input.trim();
        if (!content || streaming) {
@@ -360,6 +423,8 @@
        setDrawerError("");
        setToolEvents([]);
        setExpandedToolIds([]);
        setThinkingEvents([]);
        setThinkingExpanded(true);
        setMessages(ensureAssistantPlaceholder(nextMessages));
        setStreaming(true);
@@ -394,6 +459,9 @@
                        }
                        if (eventName === "tool_start" || eventName === "tool_result" || eventName === "tool_error") {
                            upsertToolEvent(payload);
                        }
                        if (eventName === "thinking") {
                            upsertThinkingEvent(payload);
                        }
                        if (eventName === "done") {
                            setUsage(payload);
@@ -746,26 +814,84 @@
                                    display="flex"
                                    justifyContent={message.role === "user" ? "flex-end" : "flex-start"}
                                >
                                    <Paper
                                        elevation={0}
                                        sx={{
                                            px: 1.5,
                                            py: 1.25,
                                            maxWidth: "85%",
                                            borderRadius: 2,
                                            bgcolor: message.role === "user" ? "primary.main" : "grey.100",
                                            color: message.role === "user" ? "primary.contrastText" : "text.primary",
                                            whiteSpace: "pre-wrap",
                                            wordBreak: "break-word",
                                        }}
                                    >
                                        <Typography variant="caption" display="block" sx={{ opacity: 0.72, mb: 0.5 }}>
                                            {message.role === "user" ? translate("ai.drawer.userRole") : translate("ai.drawer.assistantRole")}
                                        </Typography>
                                        <Typography variant="body2">
                                            {message.content || (streaming && index === messages.length - 1 ? translate("ai.drawer.thinking") : "")}
                                        </Typography>
                                    </Paper>
                                    <Stack spacing={1} sx={{ maxWidth: "85%", width: "100%" }} alignItems={message.role === "user" ? "flex-end" : "flex-start"}>
                                        {message.role === "assistant" && index === currentThinkingMessageIndex && !!thinkingEvents.length && (
                                            <Paper
                                                variant="outlined"
                                                sx={{
                                                    width: "100%",
                                                    borderRadius: 2,
                                                    overflow: "hidden",
                                                    bgcolor: "grey.50",
                                                }}
                                            >
                                                <Button
                                                    fullWidth
                                                    size="small"
                                                    onClick={toggleThinkingExpanded}
                                                    endIcon={thinkingExpanded
                                                        ? <ExpandLessOutlinedIcon fontSize="small" />
                                                        : <ExpandMoreOutlinedIcon fontSize="small" />}
                                                    sx={{
                                                        justifyContent: "space-between",
                                                        px: 1.25,
                                                        py: 0.75,
                                                        color: "text.primary",
                                                    }}
                                                >
                                                    {thinkingExpanded ? translate("ai.drawer.thinkingCollapse") : translate("ai.drawer.thinkingExpand")}
                                                </Button>
                                                <Collapse in={thinkingExpanded} timeout="auto" unmountOnExit>
                                                    <Stack spacing={1} sx={{ px: 1.25, pb: 1.25 }}>
                                                        {thinkingEvents.map((item) => (
                                                            <Paper key={item.phase} variant="outlined" sx={{ px: 1, py: 0.9, bgcolor: "common.white" }}>
                                                                <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
                                                                    <Typography variant="body2" fontWeight={700}>
                                                                        {item.title || translate("ai.drawer.thinkingProcess")}
                                                                    </Typography>
                                                                    <Chip
                                                                        size="small"
                                                                        color={item.status === "FAILED"
                                                                            ? "error"
                                                                            : item.status === "COMPLETED"
                                                                                ? "success"
                                                                                : item.status === "ABORTED"
                                                                                    ? "warning"
                                                                                    : "info"}
                                                                        label={getThinkingStatusLabel(item.status)}
                                                                    />
                                                                </Stack>
                                                                <Typography variant="caption" display="block" color="text.secondary" sx={{ mt: 0.75, whiteSpace: "pre-wrap" }}>
                                                                    {item.content || translate("ai.drawer.thinkingEmpty")}
                                                                </Typography>
                                                            </Paper>
                                                        ))}
                                                    </Stack>
                                                </Collapse>
                                            </Paper>
                                        )}
                                        <Paper
                                            elevation={0}
                                            sx={{
                                                px: 1.5,
                                                py: 1.25,
                                                width: "fit-content",
                                                maxWidth: "100%",
                                                borderRadius: 2,
                                                bgcolor: message.role === "user" ? "primary.main" : "grey.100",
                                                color: message.role === "user" ? "primary.contrastText" : "text.primary",
                                                whiteSpace: "pre-wrap",
                                                wordBreak: "break-word",
                                            }}
                                        >
                                            <Typography variant="caption" display="block" sx={{ opacity: 0.72, mb: 0.5 }}>
                                                {message.role === "user" ? translate("ai.drawer.userRole") : translate("ai.drawer.assistantRole")}
                                            </Typography>
                                            <Typography variant="body2">
                                                {message.content || (streaming && index === messages.length - 1 ? translate("ai.drawer.thinking") : "")}
                                            </Typography>
                                        </Paper>
                                    </Stack>
                                </Box>
                            ))}
                            <Box ref={messagesBottomRef} sx={{ height: 1 }} />
rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatThinkingEventDto.java
New file
@@ -0,0 +1,25 @@
package com.vincent.rsf.server.ai.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class AiChatThinkingEventDto {
    private String requestId;
    private Long sessionId;
    private String phase;
    private String status;
    private String title;
    private String content;
    private String toolCallId;
    private Long timestamp;
}
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/AiChatServiceImpl.java
@@ -14,6 +14,7 @@
import com.vincent.rsf.server.ai.dto.AiChatSessionDto;
import com.vincent.rsf.server.ai.dto.AiChatSessionPinRequest;
import com.vincent.rsf.server.ai.dto.AiChatSessionRenameRequest;
import com.vincent.rsf.server.ai.dto.AiChatThinkingEventDto;
import com.vincent.rsf.server.ai.dto.AiChatToolEventDto;
import com.vincent.rsf.server.ai.dto.AiResolvedConfig;
import com.vincent.rsf.server.ai.entity.AiCallLog;
@@ -173,6 +174,7 @@
        Long sessionId = request.getSessionId();
        Long callLogId = null;
        String model = null;
        ThinkingTraceEmitter thinkingTraceEmitter = null;
        try {
            ensureIdentity(userId, tenantId);
            AiResolvedConfig config = resolveConfig(request, tenantId);
@@ -221,10 +223,13 @@
                        .build());
                log.info("AI chat started, requestId={}, userId={}, tenantId={}, sessionId={}, model={}",
                        requestId, userId, tenantId, session.getId(), resolvedModel);
                thinkingTraceEmitter = new ThinkingTraceEmitter(emitter, requestId, session.getId());
                thinkingTraceEmitter.startAnalyze();
                ThinkingTraceEmitter activeThinkingTraceEmitter = thinkingTraceEmitter;
                ToolCallback[] observableToolCallbacks = wrapToolCallbacks(
                        runtime.getToolCallbacks(), emitter, requestId, session.getId(), toolCallSequence,
                        toolSuccessCount, toolFailureCount, callLogId, userId, tenantId
                        toolSuccessCount, toolFailureCount, callLogId, userId, tenantId, activeThinkingTraceEmitter
                );
                Prompt prompt = new Prompt(
                        buildPromptMessages(memory, mergedMessages, config.getPrompt(), request.getMetadata()),
@@ -237,9 +242,10 @@
                    String content = extractContent(response);
                    aiChatMemoryService.saveRound(session, userId, tenantId, request.getMessages(), content);
                    if (StringUtils.hasText(content)) {
                        markFirstToken(firstTokenAtRef, emitter, requestId, session.getId(), resolvedModel, startedAt);
                        markFirstToken(firstTokenAtRef, emitter, requestId, session.getId(), resolvedModel, startedAt, activeThinkingTraceEmitter);
                        emitStrict(emitter, "delta", buildMessagePayload("requestId", requestId, "content", content));
                    }
                    activeThinkingTraceEmitter.completeCurrentPhase();
                    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(
@@ -267,7 +273,7 @@
                            lastMetadata.set(response.getMetadata());
                            String content = extractContent(response);
                            if (StringUtils.hasText(content)) {
                                markFirstToken(firstTokenAtRef, emitter, requestId, session.getId(), resolvedModel, startedAt);
                                markFirstToken(firstTokenAtRef, emitter, requestId, session.getId(), resolvedModel, startedAt, activeThinkingTraceEmitter);
                                assistantContent.append(content);
                                emitStrict(emitter, "delta", buildMessagePayload("requestId", requestId, "content", content));
                            }
@@ -278,6 +284,7 @@
                            e == null ? "AI 模型流式调用失败" : e.getMessage(), e);
                }
                aiChatMemoryService.saveRound(session, userId, tenantId, request.getMessages(), assistantContent.toString());
                activeThinkingTraceEmitter.completeCurrentPhase();
                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(
@@ -297,12 +304,12 @@
            }
        } catch (AiChatException e) {
            handleStreamFailure(emitter, requestId, sessionId, model, startedAt, firstTokenAtRef.get(), e,
                    callLogId, toolSuccessCount.get(), toolFailureCount.get());
                    callLogId, toolSuccessCount.get(), toolFailureCount.get(), thinkingTraceEmitter);
        } catch (Exception e) {
            handleStreamFailure(emitter, requestId, sessionId, model, startedAt, firstTokenAtRef.get(),
                    buildAiException("AI_INTERNAL_ERROR", AiErrorCategory.INTERNAL, "INTERNAL",
                            e == null ? "AI 对话失败" : e.getMessage(), e),
                    callLogId, toolSuccessCount.get(), toolFailureCount.get());
                    callLogId, toolSuccessCount.get(), toolFailureCount.get(), thinkingTraceEmitter);
        } finally {
            log.debug("AI chat stream finished, requestId={}", requestId);
        }
@@ -375,9 +382,13 @@
        }
    }
    private void markFirstToken(AtomicReference<Long> firstTokenAtRef, SseEmitter emitter, String requestId, Long sessionId, String model, long startedAt) {
    private void markFirstToken(AtomicReference<Long> firstTokenAtRef, SseEmitter emitter, String requestId,
                                Long sessionId, String model, long startedAt, ThinkingTraceEmitter thinkingTraceEmitter) {
        if (!firstTokenAtRef.compareAndSet(null, System.currentTimeMillis())) {
            return;
        }
        if (thinkingTraceEmitter != null) {
            thinkingTraceEmitter.startAnswer();
        }
        emitSafely(emitter, "status", AiChatStatusDto.builder()
                .requestId(requestId)
@@ -408,10 +419,14 @@
    private void handleStreamFailure(SseEmitter emitter, String requestId, Long sessionId, String model, long startedAt,
                                     Long firstTokenAt, AiChatException exception, Long callLogId,
                                     long toolSuccessCount, long toolFailureCount) {
                                     long toolSuccessCount, long toolFailureCount,
                                     ThinkingTraceEmitter thinkingTraceEmitter) {
        if (isClientAbortException(exception)) {
            log.warn("AI chat aborted by client, requestId={}, sessionId={}, stage={}, message={}",
                    requestId, sessionId, exception.getStage(), exception.getMessage());
            if (thinkingTraceEmitter != null) {
                thinkingTraceEmitter.markTerminated("ABORTED");
            }
            emitSafely(emitter, "status", buildTerminalStatus(requestId, sessionId, "ABORTED", model, startedAt, firstTokenAt));
            aiCallLogService.failCallLog(
                    callLogId,
@@ -429,6 +444,9 @@
        }
        log.error("AI chat failed, requestId={}, sessionId={}, category={}, stage={}, message={}",
                requestId, sessionId, exception.getCategory(), exception.getStage(), exception.getMessage(), exception);
        if (thinkingTraceEmitter != null) {
            thinkingTraceEmitter.markTerminated("FAILED");
        }
        emitSafely(emitter, "status", buildTerminalStatus(requestId, sessionId, "FAILED", model, startedAt, firstTokenAt));
        emitSafely(emitter, "error", AiChatErrorDto.builder()
                .requestId(requestId)
@@ -521,7 +539,8 @@
    private ToolCallback[] wrapToolCallbacks(ToolCallback[] toolCallbacks, SseEmitter emitter, String requestId,
                                             Long sessionId, AtomicLong toolCallSequence,
                                             AtomicLong toolSuccessCount, AtomicLong toolFailureCount,
                                             Long callLogId, Long userId, Long tenantId) {
                                             Long callLogId, Long userId, Long tenantId,
                                             ThinkingTraceEmitter thinkingTraceEmitter) {
        /** 给所有工具回调套上一层可观测包装,用于实时 SSE 轨迹和审计日志落库。 */
        if (Cools.isEmpty(toolCallbacks)) {
            return toolCallbacks;
@@ -532,7 +551,7 @@
                continue;
            }
            wrappedCallbacks.add(new ObservableToolCallback(callback, emitter, requestId, sessionId, toolCallSequence,
                    toolSuccessCount, toolFailureCount, callLogId, userId, tenantId));
                    toolSuccessCount, toolFailureCount, callLogId, userId, tenantId, thinkingTraceEmitter));
        }
        return wrappedCallbacks.toArray(new ToolCallback[0]);
    }
@@ -725,6 +744,125 @@
        return false;
    }
    private class ThinkingTraceEmitter {
        private final SseEmitter emitter;
        private final String requestId;
        private final Long sessionId;
        private String currentPhase;
        private String currentStatus;
        private ThinkingTraceEmitter(SseEmitter emitter, String requestId, Long sessionId) {
            this.emitter = emitter;
            this.requestId = requestId;
            this.sessionId = sessionId;
        }
        private void startAnalyze() {
            if (currentPhase != null) {
                return;
            }
            currentPhase = "ANALYZE";
            currentStatus = "STARTED";
            emitThinkingEvent("ANALYZE", "STARTED", "正在分析问题",
                    "已接收你的问题,正在理解意图并判断是否需要调用工具。", null);
        }
        private void onToolStart(String toolName, String toolCallId) {
            switchPhase("TOOL_CALL", "STARTED", "正在调用工具", "已判断需要调用工具,正在查询相关信息。", null);
            currentStatus = "UPDATED";
            emitThinkingEvent("TOOL_CALL", "UPDATED", "正在调用工具",
                    "正在调用工具 " + safeLabel(toolName, "未知工具") + " 获取所需信息。", toolCallId);
        }
        private void onToolResult(String toolName, String toolCallId, boolean failed) {
            currentPhase = "TOOL_CALL";
            currentStatus = failed ? "FAILED" : "UPDATED";
            emitThinkingEvent("TOOL_CALL", failed ? "FAILED" : "UPDATED",
                    failed ? "工具调用失败" : "工具调用完成",
                    failed
                            ? "工具 " + safeLabel(toolName, "未知工具") + " 调用失败,正在评估失败影响并整理可用信息。"
                            : "工具 " + safeLabel(toolName, "未知工具") + " 已返回结果,正在继续分析并提炼关键信息。",
                    toolCallId);
        }
        private void startAnswer() {
            switchPhase("ANSWER", "STARTED", "正在整理答案", "已完成分析,正在组织最终回复内容。", null);
        }
        private void completeCurrentPhase() {
            if (!StringUtils.hasText(currentPhase) || isTerminalStatus(currentStatus)) {
                return;
            }
            currentStatus = "COMPLETED";
            emitThinkingEvent(currentPhase, "COMPLETED", resolveCompleteTitle(currentPhase),
                    resolveCompleteContent(currentPhase), null);
        }
        private void markTerminated(String terminalStatus) {
            if (!StringUtils.hasText(currentPhase) || isTerminalStatus(currentStatus)) {
                return;
            }
            currentStatus = terminalStatus;
            emitThinkingEvent(currentPhase, terminalStatus,
                    "ABORTED".equals(terminalStatus) ? "思考已中止" : "思考失败",
                    "ABORTED".equals(terminalStatus)
                            ? "本轮对话已被中止,思考过程提前结束。"
                            : "本轮对话在生成答案前失败,当前思考过程已停止。",
                    null);
        }
        private void switchPhase(String nextPhase, String nextStatus, String title, String content, String toolCallId) {
            if (!Objects.equals(currentPhase, nextPhase)) {
                completeCurrentPhase();
            }
            currentPhase = nextPhase;
            currentStatus = nextStatus;
            emitThinkingEvent(nextPhase, nextStatus, title, content, toolCallId);
        }
        private void emitThinkingEvent(String phase, String status, String title, String content, String toolCallId) {
            emitSafely(emitter, "thinking", AiChatThinkingEventDto.builder()
                    .requestId(requestId)
                    .sessionId(sessionId)
                    .phase(phase)
                    .status(status)
                    .title(title)
                    .content(content)
                    .toolCallId(toolCallId)
                    .timestamp(Instant.now().toEpochMilli())
                    .build());
        }
        private boolean isTerminalStatus(String status) {
            return "COMPLETED".equals(status) || "FAILED".equals(status) || "ABORTED".equals(status);
        }
        private String resolveCompleteTitle(String phase) {
            if ("ANSWER".equals(phase)) {
                return "答案整理完成";
            }
            if ("TOOL_CALL".equals(phase)) {
                return "工具分析完成";
            }
            return "问题分析完成";
        }
        private String resolveCompleteContent(String phase) {
            if ("ANSWER".equals(phase)) {
                return "最终答复已生成完成。";
            }
            if ("TOOL_CALL".equals(phase)) {
                return "工具调用阶段已结束,相关信息已整理完毕。";
            }
            return "问题意图和处理方向已分析完成。";
        }
        private String safeLabel(String value, String fallback) {
            return StringUtils.hasText(value) ? value : fallback;
        }
    }
    private class ObservableToolCallback implements ToolCallback {
        private final ToolCallback delegate;
@@ -737,11 +875,13 @@
        private final Long callLogId;
        private final Long userId;
        private final Long tenantId;
        private final ThinkingTraceEmitter thinkingTraceEmitter;
        private ObservableToolCallback(ToolCallback delegate, SseEmitter emitter, String requestId,
                                       Long sessionId, AtomicLong toolCallSequence,
                                       AtomicLong toolSuccessCount, AtomicLong toolFailureCount,
                                       Long callLogId, Long userId, Long tenantId) {
                                       Long callLogId, Long userId, Long tenantId,
                                       ThinkingTraceEmitter thinkingTraceEmitter) {
            this.delegate = delegate;
            this.emitter = emitter;
            this.requestId = requestId;
@@ -752,6 +892,7 @@
            this.callLogId = callLogId;
            this.userId = userId;
            this.tenantId = tenantId;
            this.thinkingTraceEmitter = thinkingTraceEmitter;
        }
        @Override
@@ -780,6 +921,9 @@
            String mountName = delegate instanceof MountedToolCallback ? ((MountedToolCallback) delegate).getMountName() : null;
            String toolCallId = requestId + "-tool-" + toolCallSequence.incrementAndGet();
            long startedAt = System.currentTimeMillis();
            if (thinkingTraceEmitter != null) {
                thinkingTraceEmitter.onToolStart(toolName, toolCallId);
            }
            emitSafely(emitter, "tool_start", AiChatToolEventDto.builder()
                    .requestId(requestId)
                    .sessionId(sessionId)
@@ -805,6 +949,9 @@
                        .durationMs(durationMs)
                        .timestamp(System.currentTimeMillis())
                        .build());
                if (thinkingTraceEmitter != null) {
                    thinkingTraceEmitter.onToolResult(toolName, toolCallId, false);
                }
                toolSuccessCount.incrementAndGet();
                aiCallLogService.saveMcpCallLog(callLogId, requestId, sessionId, toolCallId, mountName, toolName,
                        "COMPLETED", summarizeToolPayload(toolInput, 400), summarizeToolPayload(output, 600),
@@ -824,6 +971,9 @@
                        .durationMs(durationMs)
                        .timestamp(System.currentTimeMillis())
                        .build());
                if (thinkingTraceEmitter != null) {
                    thinkingTraceEmitter.onToolResult(toolName, toolCallId, true);
                }
                toolFailureCount.incrementAndGet();
                aiCallLogService.saveMcpCallLog(callLogId, requestId, sessionId, toolCallId, mountName, toolName,
                        "FAILED", summarizeToolPayload(toolInput, 400), null, e.getMessage(),