From b05f094ac51dce91eb8c00235226d54a04658c6d Mon Sep 17 00:00:00 2001
From: zhou zhou <3272660260@qq.com>
Date: 星期一, 23 三月 2026 15:51:17 +0800
Subject: [PATCH] #ai 页面优化

---
 /dev/null                                                                                              |  129 --------
 rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiChatOrchestrator.java           |   25 
 rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiSseEventPublisher.java          |    6 
 rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiChatFailureHandler.java         |   10 
 rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiToolObservationService.java     |  104 ++----
 rsf-server/src/test/java/com/vincent/rsf/server/AI/service/impl/chat/AiToolObservationServiceTest.java |   70 ++++
 rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiChatTraceEmitter.java           |  179 +++++++++++
 rsf-admin/src/i18n/zh.js                                                                               |    4 
 rsf-server/src/test/java/com/vincent/rsf/server/AI/service/impl/chat/AiChatTraceEmitterTest.java       |   59 +++
 rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatTraceEventDto.java                        |   18 +
 rsf-admin/src/i18n/en.js                                                                               |    4 
 rsf-admin/src/layout/AiChatDrawer.jsx                                                                  |  264 ++++++----------
 12 files changed, 484 insertions(+), 388 deletions(-)

diff --git a/rsf-admin/src/i18n/en.js b/rsf-admin/src/i18n/en.js
index 5069c5b..8d4d70e 100644
--- a/rsf-admin/src/i18n/en.js
+++ b/rsf-admin/src/i18n/en.js
@@ -519,6 +519,7 @@
             renameAction: "Rename session",
             deleteAction: "Delete session",
             toolTrace: "Tool Trace",
+            activityTrace: "Thinking & Tool Trace",
             thinkingProcess: "Thinking Process",
             thinkingExpand: "Show Thinking Process",
             thinkingCollapse: "Hide Thinking Process",
@@ -529,7 +530,10 @@
             thinkingStatusFailed: "Failed",
             thinkingStatusAborted: "Aborted",
             noToolTrace: "No tool call was triggered in this round",
+            noActivityTrace: "No thinking or tool trace in this round",
             unknownTool: "Unknown tool",
+            traceTypeThinking: "Thinking",
+            traceTypeTool: "Tool",
             toolStatusFailed: "Failed",
             toolStatusCompleted: "Completed",
             toolStatusRunning: "Running",
diff --git a/rsf-admin/src/i18n/zh.js b/rsf-admin/src/i18n/zh.js
index fd62ee9..6759812 100644
--- a/rsf-admin/src/i18n/zh.js
+++ b/rsf-admin/src/i18n/zh.js
@@ -535,6 +535,7 @@
             renameAction: "閲嶅懡鍚嶄細璇�",
             deleteAction: "鍒犻櫎浼氳瘽",
             toolTrace: "宸ュ叿璋冪敤杞ㄨ抗",
+            activityTrace: "鎬濈淮閾句笌宸ュ叿杞ㄨ抗",
             thinkingProcess: "鎬濊�冭繃绋�",
             thinkingExpand: "灞曞紑鎬濊�冭繃绋�",
             thinkingCollapse: "鏀惰捣鎬濊�冭繃绋�",
@@ -545,7 +546,10 @@
             thinkingStatusFailed: "澶辫触",
             thinkingStatusAborted: "宸蹭腑姝�",
             noToolTrace: "褰撳墠杞湭瑙﹀彂宸ュ叿璋冪敤",
+            noActivityTrace: "褰撳墠杞皻鏃犳�濊�冩垨宸ュ叿杞ㄨ抗",
             unknownTool: "鏈煡宸ュ叿",
+            traceTypeThinking: "鎬濈淮閾�",
+            traceTypeTool: "宸ュ叿",
             toolStatusFailed: "澶辫触",
             toolStatusCompleted: "瀹屾垚",
             toolStatusRunning: "鎵ц涓�",
diff --git a/rsf-admin/src/layout/AiChatDrawer.jsx b/rsf-admin/src/layout/AiChatDrawer.jsx
index fc1eb06..cbbd584 100644
--- a/rsf-admin/src/layout/AiChatDrawer.jsx
+++ b/rsf-admin/src/layout/AiChatDrawer.jsx
@@ -49,11 +49,6 @@
 const DEFAULT_PROMPT_CODE = "home.default";
 const AI_CHAT_DRAWER_Z_INDEX = 1400;
 const AI_CHAT_DIALOG_Z_INDEX = AI_CHAT_DRAWER_Z_INDEX + 20;
-const THINKING_PHASE_ORDER = {
-    ANALYZE: 0,
-    TOOL_CALL: 1,
-    ANSWER: 2,
-};
 
 const normalizeMarkdownContent = (content) => {
     if (!content) {
@@ -239,10 +234,8 @@
     const [sessions, setSessions] = useState([]);
     const [persistedMessages, setPersistedMessages] = useState([]);
     const [messages, setMessages] = useState([]);
-    const [toolEvents, setToolEvents] = useState([]);
-    const [expandedToolIds, setExpandedToolIds] = useState([]);
-    const [thinkingEvents, setThinkingEvents] = useState([]);
-    const [thinkingExpanded, setThinkingExpanded] = useState(true);
+    const [traceEvents, setTraceEvents] = useState([]);
+    const [expandedTraceIds, setExpandedTraceIds] = useState([]);
     const [input, setInput] = useState("");
     const [loadingRuntime, setLoadingRuntime] = useState(false);
     const [streaming, setStreaming] = useState(false);
@@ -286,18 +279,6 @@
         };
     }, [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) {
             setRuntimePanelExpanded(false);
@@ -322,10 +303,8 @@
     }, [open, messages, streaming]);
 
     const initializeDrawer = async (targetSessionId = null) => {
-        setToolEvents([]);
-        setExpandedToolIds([]);
-        setThinkingEvents([]);
-        setThinkingExpanded(true);
+        setTraceEvents([]);
+        setExpandedTraceIds([]);
         await Promise.all([
             loadRuntime(targetSessionId),
             loadSessions(sessionKeyword),
@@ -370,10 +349,8 @@
         setSessionId(null);
         setPersistedMessages([]);
         setMessages([]);
-        setToolEvents([]);
-        setExpandedToolIds([]);
-        setThinkingEvents([]);
-        setThinkingExpanded(true);
+        setTraceEvents([]);
+        setExpandedTraceIds([]);
         setUsage(null);
         setDrawerError("");
     };
@@ -393,10 +370,8 @@
             return;
         }
         setUsage(null);
-        setToolEvents([]);
-        setExpandedToolIds([]);
-        setThinkingEvents([]);
-        setThinkingExpanded(true);
+        setTraceEvents([]);
+        setExpandedTraceIds([]);
         await loadRuntime(targetSessionId);
     };
 
@@ -562,42 +537,15 @@
         return next;
     };
 
-    const upsertToolEvent = (payload) => {
-        if (!payload?.toolCallId) {
+    const appendTraceEvent = (payload) => {
+        if (!payload?.traceId) {
             return;
         }
-        setToolEvents((prev) => {
-            const index = prev.findIndex((item) => item.toolCallId === payload.toolCallId);
-            if (index < 0) {
-                return [...prev, payload];
-            }
-            const next = [...prev];
-            next[index] = { ...next[index], ...payload };
-            return next;
-        });
-    };
-
-    const toggleToolEventExpanded = (toolCallId) => {
-        if (!toolCallId) {
-            return;
-        }
-        setExpandedToolIds((prev) => (
-            prev.includes(toolCallId)
-                ? prev.filter((item) => item !== toolCallId)
-                : [...prev, toolCallId]
-        ));
-    };
-
-    const upsertThinkingEvent = (payload) => {
-        if (!payload?.phase) {
-            return;
-        }
-        setThinkingEvents((prev) => {
-            const index = prev.findIndex((item) => item.phase === payload.phase);
+        setTraceEvents((prev) => {
+            const index = prev.findIndex((item) => item.traceId === payload.traceId);
             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)
+                    (left?.sequence ?? 0) - (right?.sequence ?? 0)
                 ));
             }
             const next = [...prev];
@@ -606,8 +554,15 @@
         });
     };
 
-    const toggleThinkingExpanded = () => {
-        setThinkingExpanded((prev) => !prev);
+    const toggleTraceEventExpanded = (traceId) => {
+        if (!traceId) {
+            return;
+        }
+        setExpandedTraceIds((prev) => (
+            prev.includes(traceId)
+                ? prev.filter((item) => item !== traceId)
+                : [...prev, traceId]
+        ));
     };
 
     const getThinkingStatusLabel = (status) => {
@@ -626,6 +581,16 @@
         return translate("ai.drawer.thinkingStatusStarted");
     };
 
+    const getToolStatusLabel = (status) => {
+        if (status === "FAILED") {
+            return translate("ai.drawer.toolStatusFailed");
+        }
+        if (status === "COMPLETED") {
+            return translate("ai.drawer.toolStatusCompleted");
+        }
+        return translate("ai.drawer.toolStatusRunning");
+    };
+
     const handleSend = async () => {
         const content = input.trim();
         if (!content || streaming) {
@@ -636,10 +601,8 @@
         setInput("");
         setUsage(null);
         setDrawerError("");
-        setToolEvents([]);
-        setExpandedToolIds([]);
-        setThinkingEvents([]);
-        setThinkingExpanded(true);
+        setTraceEvents([]);
+        setExpandedTraceIds([]);
         setMessages(ensureAssistantPlaceholder(nextMessages));
         setStreaming(true);
 
@@ -676,11 +639,8 @@
                         if (eventName === "delta") {
                             appendAssistantDelta(payload?.content || "");
                         }
-                        if (eventName === "tool_start" || eventName === "tool_result" || eventName === "tool_error") {
-                            upsertToolEvent(payload);
-                        }
-                        if (eventName === "thinking") {
-                            upsertThinkingEvent(payload);
+                        if (eventName === "trace") {
+                            appendTraceEvent(payload);
                         }
                         if (eventName === "done") {
                             setUsage(payload);
@@ -864,71 +824,106 @@
                     >
                         <Box px={2} py={1.5} display="flex" flexDirection="column" minHeight={0}>
                             <Typography variant="subtitle2" mb={1}>
-                                {translate("ai.drawer.toolTrace")}
+                                {translate("ai.drawer.activityTrace")}
                             </Typography>
                             <Paper variant="outlined" sx={{ flex: 1, minHeight: { xs: 140, md: 0 }, overflow: "hidden", bgcolor: "grey.50" }}>
-                                {!toolEvents.length ? (
+                                {!traceEvents.length ? (
                                     <Box px={1.5} py={1.25}>
                                         <Typography variant="body2" color="text.secondary">
-                                            {translate("ai.drawer.noToolTrace")}
+                                            {translate("ai.drawer.noActivityTrace")}
                                         </Typography>
                                     </Box>
                                 ) : (
                                     <Stack spacing={1} sx={{ p: 1.25, maxHeight: { xs: 220, md: "calc(100vh - 180px)" }, overflow: "auto" }}>
-                                        {toolEvents.map((item) => (
+                                        {traceEvents.map((item) => (
                                             <Paper
-                                                key={item.toolCallId}
+                                                key={item.traceId}
                                                 variant="outlined"
                                                 sx={{
                                                     p: 1.25,
-                                                    bgcolor: item.status === "FAILED" ? "error.lighter" : "common.white",
-                                                    borderColor: item.status === "FAILED" ? "error.light" : "divider",
+                                                    bgcolor: item.status === "FAILED"
+                                                        ? "error.lighter"
+                                                        : item.traceType === "thinking"
+                                                            ? "info.lighter"
+                                                            : "common.white",
+                                                    borderColor: item.status === "FAILED"
+                                                        ? "error.light"
+                                                        : item.traceType === "thinking"
+                                                            ? "info.light"
+                                                            : "divider",
                                                 }}
                                             >
                                                 <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
+                                                    <Chip
+                                                        size="small"
+                                                        variant="outlined"
+                                                        color={item.traceType === "thinking" ? "info" : "primary"}
+                                                        label={translate(item.traceType === "thinking" ? "ai.drawer.traceTypeThinking" : "ai.drawer.traceTypeTool")}
+                                                    />
                                                     <Typography variant="body2" fontWeight={700}>
-                                                        {item.toolName || translate("ai.drawer.unknownTool")}
+                                                        {item.traceType === "thinking"
+                                                            ? (item.title || translate("ai.drawer.thinkingProcess"))
+                                                            : (item.toolName || item.title || translate("ai.drawer.unknownTool"))}
                                                     </Typography>
                                                     <Chip
                                                         size="small"
-                                                        color={item.status === "FAILED" ? "error" : item.status === "COMPLETED" ? "success" : "info"}
-                                                        label={translate(item.status === "FAILED" ? "ai.drawer.toolStatusFailed" : item.status === "COMPLETED" ? "ai.drawer.toolStatusCompleted" : "ai.drawer.toolStatusRunning")}
+                                                        color={item.status === "FAILED"
+                                                            ? "error"
+                                                            : item.status === "COMPLETED"
+                                                                ? "success"
+                                                                : item.status === "ABORTED"
+                                                                    ? "warning"
+                                                                    : "info"}
+                                                        label={item.traceType === "thinking"
+                                                            ? getThinkingStatusLabel(item.status)
+                                                            : getToolStatusLabel(item.status)}
                                                     />
                                                     {item.durationMs != null && (
                                                         <Typography variant="caption" color="text.secondary">
                                                             {item.durationMs} ms
                                                         </Typography>
                                                     )}
-                                                    {(item.inputSummary || item.outputSummary || item.errorMessage) && (
+                                                    {item.traceType === "tool" && (item.inputSummary || item.outputSummary || item.errorMessage) && (
                                                         <Button
                                                             size="small"
-                                                            onClick={() => toggleToolEventExpanded(item.toolCallId)}
-                                                            endIcon={expandedToolIds.includes(item.toolCallId)
+                                                            onClick={() => toggleTraceEventExpanded(item.traceId)}
+                                                            endIcon={expandedTraceIds.includes(item.traceId)
                                                                 ? <ExpandLessOutlinedIcon fontSize="small" />
                                                                 : <ExpandMoreOutlinedIcon fontSize="small" />}
                                                             sx={{ ml: "auto", minWidth: "auto", px: 0.5 }}
                                                         >
-                                                            {expandedToolIds.includes(item.toolCallId) ? translate("ai.drawer.collapseDetail") : translate("ai.drawer.viewDetail")}
+                                                            {expandedTraceIds.includes(item.traceId) ? translate("ai.drawer.collapseDetail") : translate("ai.drawer.viewDetail")}
                                                         </Button>
                                                     )}
                                                 </Stack>
-                                                <Collapse in={expandedToolIds.includes(item.toolCallId)} timeout="auto" unmountOnExit>
-                                                    {!!item.inputSummary && (
-                                                        <Typography variant="caption" display="block" sx={{ mt: 0.75, whiteSpace: "pre-wrap" }}>
-                                                            {translate("ai.drawer.toolInput", { value: item.inputSummary })}
-                                                        </Typography>
-                                                    )}
-                                                    {!!item.outputSummary && (
-                                                        <Typography variant="caption" display="block" sx={{ mt: 0.75, whiteSpace: "pre-wrap" }}>
-                                                            {translate("ai.drawer.toolOutput", { value: item.outputSummary })}
-                                                        </Typography>
-                                                    )}
-                                                    {!!item.errorMessage && (
-                                                        <Typography variant="caption" color="error.main" display="block" sx={{ mt: 0.75, whiteSpace: "pre-wrap" }}>
-                                                            {translate("ai.drawer.toolError", { value: item.errorMessage })}
-                                                        </Typography>
-                                                    )}
-                                                </Collapse>
+                                                {item.traceType === "thinking" ? (
+                                                    <Typography variant="caption" display="block" color="text.secondary" sx={{ mt: 0.75, whiteSpace: "pre-wrap" }}>
+                                                        {item.content || translate("ai.drawer.thinkingEmpty")}
+                                                    </Typography>
+                                                ) : (
+                                                    <Collapse in={expandedTraceIds.includes(item.traceId)} timeout="auto" unmountOnExit>
+                                                        {!!item.title && (
+                                                            <Typography variant="caption" display="block" color="text.secondary" sx={{ mt: 0.75, whiteSpace: "pre-wrap" }}>
+                                                                {item.title}
+                                                            </Typography>
+                                                        )}
+                                                        {!!item.inputSummary && (
+                                                            <Typography variant="caption" display="block" sx={{ mt: 0.75, whiteSpace: "pre-wrap" }}>
+                                                                {translate("ai.drawer.toolInput", { value: item.inputSummary })}
+                                                            </Typography>
+                                                        )}
+                                                        {!!item.outputSummary && (
+                                                            <Typography variant="caption" display="block" sx={{ mt: 0.75, whiteSpace: "pre-wrap" }}>
+                                                                {translate("ai.drawer.toolOutput", { value: item.outputSummary })}
+                                                            </Typography>
+                                                        )}
+                                                        {!!item.errorMessage && (
+                                                            <Typography variant="caption" color="error.main" display="block" sx={{ mt: 0.75, whiteSpace: "pre-wrap" }}>
+                                                                {translate("ai.drawer.toolError", { value: item.errorMessage })}
+                                                            </Typography>
+                                                        )}
+                                                    </Collapse>
+                                                )}
                                             </Paper>
                                         ))}
                                     </Stack>
@@ -1051,61 +1046,6 @@
                                     justifyContent={message.role === "user" ? "flex-end" : "flex-start"}
                                 >
                                     <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={{
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatThinkingEventDto.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatThinkingEventDto.java
deleted file mode 100644
index 12183c7..0000000
--- a/rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatThinkingEventDto.java
+++ /dev/null
@@ -1,25 +0,0 @@
-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;
-}
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/AiChatTraceEventDto.java
similarity index 68%
rename from rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatToolEventDto.java
rename to rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatTraceEventDto.java
index dbeb6de..2273171 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/AiChatTraceEventDto.java
@@ -5,19 +5,31 @@
 
 @Data
 @Builder
-public class AiChatToolEventDto {
+public class AiChatTraceEventDto {
 
     private String requestId;
 
     private Long sessionId;
+
+    private String traceId;
+
+    private Long sequence;
+
+    private String traceType;
+
+    private String phase;
+
+    private String status;
+
+    private String title;
+
+    private String content;
 
     private String toolCallId;
 
     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/service/impl/chat/AiChatFailureHandler.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiChatFailureHandler.java
index 9fa35c5..d9f9d91 100644
--- a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiChatFailureHandler.java
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiChatFailureHandler.java
@@ -28,13 +28,13 @@
     public void handleStreamFailure(SseEmitter emitter, String requestId, Long sessionId, String model, long startedAt,
                                     Long firstTokenAt, AiChatException exception, Long callLogId,
                                     long toolSuccessCount, long toolFailureCount,
-                                    AiThinkingTraceEmitter thinkingTraceEmitter,
+                                    AiChatTraceEmitter traceEmitter,
                                     Long tenantId, Long userId, String promptCode) {
         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");
+            if (traceEmitter != null) {
+                traceEmitter.markTerminated("ABORTED");
             }
             aiSseEventPublisher.emitSafely(emitter, "status",
                     aiSseEventPublisher.buildTerminalStatus(requestId, sessionId, "ABORTED", model, startedAt, firstTokenAt));
@@ -55,8 +55,8 @@
         }
         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");
+        if (traceEmitter != null) {
+            traceEmitter.markTerminated("FAILED");
         }
         aiSseEventPublisher.emitSafely(emitter, "status",
                 aiSseEventPublisher.buildTerminalStatus(requestId, sessionId, "FAILED", model, startedAt, firstTokenAt));
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiChatOrchestrator.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiChatOrchestrator.java
index 055192b..8b6b510 100644
--- a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiChatOrchestrator.java
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiChatOrchestrator.java
@@ -57,6 +57,7 @@
         String requestId = request.getRequestId();
         long startedAt = System.currentTimeMillis();
         AtomicReference<Long> firstTokenAtRef = new AtomicReference<>();
+        AtomicLong traceSequence = new AtomicLong(0);
         AtomicLong toolCallSequence = new AtomicLong(0);
         AtomicLong toolSuccessCount = new AtomicLong(0);
         AtomicLong toolFailureCount = new AtomicLong(0);
@@ -64,7 +65,7 @@
         Long callLogId = null;
         String model = null;
         String resolvedPromptCode = request.getPromptCode();
-        AiThinkingTraceEmitter thinkingTraceEmitter = null;
+        AiChatTraceEmitter traceEmitter = null;
         try {
             ensureIdentity(userId, tenantId);
             AiResolvedConfig config = resolveConfig(request, tenantId);
@@ -115,13 +116,13 @@
                         .build());
                 log.info("AI chat started, requestId={}, userId={}, tenantId={}, sessionId={}, model={}",
                         requestId, userId, tenantId, session.getId(), resolvedModel);
-                thinkingTraceEmitter = new AiThinkingTraceEmitter(aiSseEventPublisher, emitter, requestId, session.getId());
-                thinkingTraceEmitter.startAnalyze();
-                AiThinkingTraceEmitter activeThinkingTraceEmitter = thinkingTraceEmitter;
+                traceEmitter = new AiChatTraceEmitter(aiSseEventPublisher, emitter, requestId, session.getId(), traceSequence);
+                traceEmitter.startAnalyze();
+                AiChatTraceEmitter activeTraceEmitter = traceEmitter;
 
                 ToolCallback[] observableToolCallbacks = aiToolObservationService.wrapToolCallbacks(
-                        runtime.getToolCallbacks(), emitter, requestId, session.getId(), toolCallSequence,
-                        toolSuccessCount, toolFailureCount, callLogId, userId, tenantId, activeThinkingTraceEmitter
+                        runtime.getToolCallbacks(), requestId, session.getId(), toolCallSequence,
+                        toolSuccessCount, toolFailureCount, callLogId, userId, tenantId, activeTraceEmitter
                 );
                 Prompt prompt = new Prompt(
                         aiPromptMessageBuilder.buildPromptMessages(memory, mergedMessages, config.getPrompt(), request.getMetadata()),
@@ -134,10 +135,10 @@
                     String content = extractContent(response);
                     aiChatMemoryService.saveRound(session, userId, tenantId, request.getMessages(), content);
                     if (StringUtils.hasText(content)) {
-                        aiSseEventPublisher.markFirstToken(firstTokenAtRef, emitter, requestId, session.getId(), resolvedModel, startedAt, activeThinkingTraceEmitter);
+                        aiSseEventPublisher.markFirstToken(firstTokenAtRef, emitter, requestId, session.getId(), resolvedModel, startedAt, activeTraceEmitter);
                         aiSseEventPublisher.emitStrict(emitter, "delta", aiSseEventPublisher.buildMessagePayload("requestId", requestId, "content", content));
                     }
-                    activeThinkingTraceEmitter.completeCurrentPhase();
+                    activeTraceEmitter.completeCurrentPhase();
                     aiSseEventPublisher.emitDone(emitter, requestId, response.getMetadata(), config.getAiParam().getModel(),
                             session.getId(), startedAt, firstTokenAtRef.get());
                     aiSseEventPublisher.emitSafely(emitter, "status",
@@ -169,7 +170,7 @@
                                 lastMetadata.set(response.getMetadata());
                                 String content = extractContent(response);
                                 if (StringUtils.hasText(content)) {
-                                    aiSseEventPublisher.markFirstToken(firstTokenAtRef, emitter, requestId, session.getId(), resolvedModel, startedAt, activeThinkingTraceEmitter);
+                                    aiSseEventPublisher.markFirstToken(firstTokenAtRef, emitter, requestId, session.getId(), resolvedModel, startedAt, activeTraceEmitter);
                                     assistantContent.append(content);
                                     aiSseEventPublisher.emitStrict(emitter, "delta",
                                             aiSseEventPublisher.buildMessagePayload("requestId", requestId, "content", content));
@@ -181,7 +182,7 @@
                             e == null ? "AI 妯″瀷娴佸紡璋冪敤澶辫触" : e.getMessage(), e);
                 }
                 aiChatMemoryService.saveRound(session, userId, tenantId, request.getMessages(), assistantContent.toString());
-                activeThinkingTraceEmitter.completeCurrentPhase();
+                activeTraceEmitter.completeCurrentPhase();
                 aiSseEventPublisher.emitDone(emitter, requestId, lastMetadata.get(), config.getAiParam().getModel(),
                         session.getId(), startedAt, firstTokenAtRef.get());
                 aiSseEventPublisher.emitSafely(emitter, "status",
@@ -205,13 +206,13 @@
             }
         } catch (AiChatException e) {
             aiChatFailureHandler.handleStreamFailure(emitter, requestId, sessionId, model, startedAt, firstTokenAtRef.get(), e,
-                    callLogId, toolSuccessCount.get(), toolFailureCount.get(), thinkingTraceEmitter,
+                    callLogId, toolSuccessCount.get(), toolFailureCount.get(), traceEmitter,
                     tenantId, userId, resolvedPromptCode);
         } catch (Exception e) {
             aiChatFailureHandler.handleStreamFailure(emitter, requestId, sessionId, model, startedAt, firstTokenAtRef.get(),
                     aiChatFailureHandler.buildAiException("AI_INTERNAL_ERROR", AiErrorCategory.INTERNAL, "INTERNAL",
                             e == null ? "AI 瀵硅瘽澶辫触" : e.getMessage(), e),
-                    callLogId, toolSuccessCount.get(), toolFailureCount.get(), thinkingTraceEmitter,
+                    callLogId, toolSuccessCount.get(), toolFailureCount.get(), traceEmitter,
                     tenantId, userId, resolvedPromptCode);
         } finally {
             log.debug("AI chat stream finished, requestId={}", requestId);
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiChatTraceEmitter.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiChatTraceEmitter.java
new file mode 100644
index 0000000..ccedff3
--- /dev/null
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiChatTraceEmitter.java
@@ -0,0 +1,179 @@
+package com.vincent.rsf.server.ai.service.impl.chat;
+
+import com.vincent.rsf.server.ai.dto.AiChatTraceEventDto;
+import org.springframework.util.StringUtils;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import java.time.Instant;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+
+public class AiChatTraceEmitter {
+
+    private static final String TRACE_EVENT_NAME = "trace";
+
+    private final AiSseEventPublisher aiSseEventPublisher;
+    private final SseEmitter emitter;
+    private final String requestId;
+    private final Long sessionId;
+    private final AtomicLong traceSequence;
+    private final Map<String, Long> traceOrderMap = new ConcurrentHashMap<>();
+    private String currentPhase;
+    private String currentStatus;
+
+    public AiChatTraceEmitter(AiSseEventPublisher aiSseEventPublisher, SseEmitter emitter, String requestId,
+                              Long sessionId, AtomicLong traceSequence) {
+        this.aiSseEventPublisher = aiSseEventPublisher;
+        this.emitter = emitter;
+        this.requestId = requestId;
+        this.sessionId = sessionId;
+        this.traceSequence = traceSequence;
+    }
+
+    public void startAnalyze() {
+        if (currentPhase != null) {
+            return;
+        }
+        currentPhase = "ANALYZE";
+        currentStatus = "STARTED";
+        emitThinkingEvent("ANALYZE", "STARTED", "姝e湪鍒嗘瀽闂",
+                "宸叉帴鏀朵綘鐨勯棶棰橈紝姝e湪鐞嗚В鎰忓浘骞跺垽鏂槸鍚﹂渶瑕佽皟鐢ㄥ伐鍏枫��", null);
+    }
+
+    public void onToolStart(String toolName, String mountName, String toolCallId, String inputSummary, long timestamp) {
+        completeCurrentPhase();
+        emitToolEvent("STARTED", "寮�濮嬭皟鐢ㄥ伐鍏�", null, toolCallId, toolName, mountName, inputSummary,
+                null, null, null, timestamp);
+    }
+
+    public void onToolResult(String toolName, String mountName, String toolCallId, String inputSummary,
+                             String outputSummary, String errorMessage, Long durationMs, long timestamp,
+                             boolean failed) {
+        emitToolEvent(failed ? "FAILED" : "COMPLETED",
+                failed ? "宸ュ叿璋冪敤澶辫触" : "宸ュ叿璋冪敤瀹屾垚",
+                null,
+                toolCallId,
+                toolName,
+                mountName,
+                inputSummary,
+                outputSummary,
+                errorMessage,
+                durationMs,
+                timestamp);
+    }
+
+    public void startAnswer() {
+        switchPhase("ANSWER", "STARTED", "姝e湪鏁寸悊绛旀", "宸插畬鎴愬垎鏋愶紝姝e湪缁勭粐鏈�缁堝洖澶嶅唴瀹广��", null);
+    }
+
+    public void completeCurrentPhase() {
+        if (!StringUtils.hasText(currentPhase) || isTerminalStatus(currentStatus)) {
+            return;
+        }
+        currentStatus = "COMPLETED";
+        emitThinkingEvent(currentPhase, "COMPLETED", resolveCompleteTitle(currentPhase),
+                resolveCompleteContent(currentPhase), null);
+    }
+
+    public 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) {
+        emitTraceEvent(AiChatTraceEventDto.builder()
+                .requestId(requestId)
+                .sessionId(sessionId)
+                .traceType("thinking")
+                .phase(phase)
+                .status(status)
+                .title(title)
+                .content(content)
+                .toolCallId(toolCallId)
+                .timestamp(Instant.now().toEpochMilli())
+                .build(), buildThinkingTraceId(phase));
+    }
+
+    private void emitToolEvent(String status, String title, String content, String toolCallId, String toolName,
+                               String mountName, String inputSummary, String outputSummary, String errorMessage,
+                               Long durationMs, long timestamp) {
+        emitTraceEvent(AiChatTraceEventDto.builder()
+                .requestId(requestId)
+                .sessionId(sessionId)
+                .traceType("tool")
+                .status(status)
+                .title(title)
+                .content(content)
+                .toolCallId(toolCallId)
+                .toolName(toolName)
+                .mountName(mountName)
+                .inputSummary(inputSummary)
+                .outputSummary(outputSummary)
+                .errorMessage(errorMessage)
+                .durationMs(durationMs)
+                .timestamp(timestamp)
+                .build(), buildToolTraceId(toolCallId));
+    }
+
+    private void emitTraceEvent(AiChatTraceEventDto payload, String traceId) {
+        long sequence = traceOrderMap.computeIfAbsent(traceId, ignored -> traceSequence.incrementAndGet());
+        payload.setSequence(sequence);
+        payload.setTraceId(traceId);
+        aiSseEventPublisher.emitSafely(emitter, TRACE_EVENT_NAME, payload);
+    }
+
+    private String buildThinkingTraceId(String phase) {
+        return requestId + "-thinking-" + phase;
+    }
+
+    private String buildToolTraceId(String toolCallId) {
+        return toolCallId;
+    }
+
+    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;
+    }
+}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiSseEventPublisher.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiSseEventPublisher.java
index aace1a3..0590643 100644
--- a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiSseEventPublisher.java
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiSseEventPublisher.java
@@ -28,12 +28,12 @@
     private final ObjectMapper objectMapper;
 
     public void markFirstToken(AtomicReference<Long> firstTokenAtRef, SseEmitter emitter, String requestId,
-                               Long sessionId, String model, long startedAt, AiThinkingTraceEmitter thinkingTraceEmitter) {
+                               Long sessionId, String model, long startedAt, AiChatTraceEmitter traceEmitter) {
         if (!firstTokenAtRef.compareAndSet(null, System.currentTimeMillis())) {
             return;
         }
-        if (thinkingTraceEmitter != null) {
-            thinkingTraceEmitter.startAnswer();
+        if (traceEmitter != null) {
+            traceEmitter.startAnswer();
         }
         emitSafely(emitter, "status", AiChatStatusDto.builder()
                 .requestId(requestId)
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiThinkingTraceEmitter.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiThinkingTraceEmitter.java
deleted file mode 100644
index 206ad51..0000000
--- a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiThinkingTraceEmitter.java
+++ /dev/null
@@ -1,129 +0,0 @@
-package com.vincent.rsf.server.ai.service.impl.chat;
-
-import com.vincent.rsf.server.ai.dto.AiChatThinkingEventDto;
-import org.springframework.util.StringUtils;
-import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
-
-import java.time.Instant;
-import java.util.Objects;
-
-public class AiThinkingTraceEmitter {
-
-    private final AiSseEventPublisher aiSseEventPublisher;
-    private final SseEmitter emitter;
-    private final String requestId;
-    private final Long sessionId;
-    private String currentPhase;
-    private String currentStatus;
-
-    public AiThinkingTraceEmitter(AiSseEventPublisher aiSseEventPublisher, SseEmitter emitter, String requestId, Long sessionId) {
-        this.aiSseEventPublisher = aiSseEventPublisher;
-        this.emitter = emitter;
-        this.requestId = requestId;
-        this.sessionId = sessionId;
-    }
-
-    public void startAnalyze() {
-        if (currentPhase != null) {
-            return;
-        }
-        currentPhase = "ANALYZE";
-        currentStatus = "STARTED";
-        emitThinkingEvent("ANALYZE", "STARTED", "姝e湪鍒嗘瀽闂",
-                "宸叉帴鏀朵綘鐨勯棶棰橈紝姝e湪鐞嗚В鎰忓浘骞跺垽鏂槸鍚﹂渶瑕佽皟鐢ㄥ伐鍏枫��", null);
-    }
-
-    public void onToolStart(String toolName, String toolCallId) {
-        switchPhase("TOOL_CALL", "STARTED", "姝e湪璋冪敤宸ュ叿", "宸插垽鏂渶瑕佽皟鐢ㄥ伐鍏凤紝姝e湪鏌ヨ鐩稿叧淇℃伅銆�", null);
-        currentStatus = "UPDATED";
-        emitThinkingEvent("TOOL_CALL", "UPDATED", "姝e湪璋冪敤宸ュ叿",
-                "姝e湪璋冪敤宸ュ叿 " + safeLabel(toolName, "鏈煡宸ュ叿") + " 鑾峰彇鎵�闇�淇℃伅銆�", toolCallId);
-    }
-
-    public 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, "鏈煡宸ュ叿") + " 宸茶繑鍥炵粨鏋滐紝姝e湪缁х画鍒嗘瀽骞舵彁鐐煎叧閿俊鎭��",
-                toolCallId);
-    }
-
-    public void startAnswer() {
-        switchPhase("ANSWER", "STARTED", "姝e湪鏁寸悊绛旀", "宸插畬鎴愬垎鏋愶紝姝e湪缁勭粐鏈�缁堝洖澶嶅唴瀹广��", null);
-    }
-
-    public void completeCurrentPhase() {
-        if (!StringUtils.hasText(currentPhase) || isTerminalStatus(currentStatus)) {
-            return;
-        }
-        currentStatus = "COMPLETED";
-        emitThinkingEvent(currentPhase, "COMPLETED", resolveCompleteTitle(currentPhase),
-                resolveCompleteContent(currentPhase), null);
-    }
-
-    public 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) {
-        aiSseEventPublisher.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;
-    }
-}
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiToolObservationService.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiToolObservationService.java
index 82d2019..72b6385 100644
--- a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiToolObservationService.java
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiToolObservationService.java
@@ -1,7 +1,6 @@
 package com.vincent.rsf.server.ai.service.impl.chat;
 
 import com.vincent.rsf.framework.exception.CoolException;
-import com.vincent.rsf.server.ai.dto.AiChatToolEventDto;
 import com.vincent.rsf.server.ai.service.AiCallLogService;
 import com.vincent.rsf.server.ai.service.MountedToolCallback;
 import com.vincent.rsf.server.ai.store.AiCachedToolResult;
@@ -11,7 +10,6 @@
 import org.springframework.ai.tool.ToolCallback;
 import org.springframework.stereotype.Component;
 import org.springframework.util.StringUtils;
-import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -21,15 +19,14 @@
 @RequiredArgsConstructor
 public class AiToolObservationService {
 
-    private final AiSseEventPublisher aiSseEventPublisher;
     private final AiToolResultStore aiToolResultStore;
     private final AiCallLogService aiCallLogService;
 
-    public ToolCallback[] wrapToolCallbacks(ToolCallback[] toolCallbacks, SseEmitter emitter, String requestId,
+    public ToolCallback[] wrapToolCallbacks(ToolCallback[] toolCallbacks, String requestId,
                                             Long sessionId, AtomicLong toolCallSequence,
                                             AtomicLong toolSuccessCount, AtomicLong toolFailureCount,
                                             Long callLogId, Long userId, Long tenantId,
-                                            AiThinkingTraceEmitter thinkingTraceEmitter) {
+                                            AiChatTraceEmitter traceEmitter) {
         if (toolCallbacks == null || toolCallbacks.length == 0) {
             return toolCallbacks;
         }
@@ -38,8 +35,8 @@
             if (callback == null) {
                 continue;
             }
-            wrappedCallbacks.add(new ObservableToolCallback(callback, emitter, requestId, sessionId, toolCallSequence,
-                    toolSuccessCount, toolFailureCount, callLogId, userId, tenantId, thinkingTraceEmitter));
+            wrappedCallbacks.add(new ObservableToolCallback(callback, requestId, sessionId, toolCallSequence,
+                    toolSuccessCount, toolFailureCount, callLogId, userId, tenantId, traceEmitter));
         }
         return wrappedCallbacks.toArray(new ToolCallback[0]);
     }
@@ -58,7 +55,6 @@
     private class ObservableToolCallback implements ToolCallback {
 
         private final ToolCallback delegate;
-        private final SseEmitter emitter;
         private final String requestId;
         private final Long sessionId;
         private final AtomicLong toolCallSequence;
@@ -67,15 +63,14 @@
         private final Long callLogId;
         private final Long userId;
         private final Long tenantId;
-        private final AiThinkingTraceEmitter thinkingTraceEmitter;
+        private final AiChatTraceEmitter traceEmitter;
 
-        private ObservableToolCallback(ToolCallback delegate, SseEmitter emitter, String requestId,
+        private ObservableToolCallback(ToolCallback delegate, String requestId,
                                        Long sessionId, AtomicLong toolCallSequence,
                                        AtomicLong toolSuccessCount, AtomicLong toolFailureCount,
                                        Long callLogId, Long userId, Long tenantId,
-                                       AiThinkingTraceEmitter thinkingTraceEmitter) {
+                                       AiChatTraceEmitter traceEmitter) {
             this.delegate = delegate;
-            this.emitter = emitter;
             this.requestId = requestId;
             this.sessionId = sessionId;
             this.toolCallSequence = toolCallSequence;
@@ -84,7 +79,7 @@
             this.callLogId = callLogId;
             this.userId = userId;
             this.tenantId = tenantId;
-            this.thinkingTraceEmitter = thinkingTraceEmitter;
+            this.traceEmitter = traceEmitter;
         }
 
         @Override
@@ -108,95 +103,56 @@
             String mountName = delegate instanceof MountedToolCallback ? ((MountedToolCallback) delegate).getMountName() : null;
             String toolCallId = requestId + "-tool-" + toolCallSequence.incrementAndGet();
             long startedAt = System.currentTimeMillis();
+            String inputSummary = summarizeToolPayload(toolInput, 400);
             AiCachedToolResult cachedToolResult = aiToolResultStore.getToolResult(tenantId, requestId, toolName, toolInput);
             if (cachedToolResult != null) {
-                aiSseEventPublisher.emitSafely(emitter, "tool_result", AiChatToolEventDto.builder()
-                        .requestId(requestId)
-                        .sessionId(sessionId)
-                        .toolCallId(toolCallId)
-                        .toolName(toolName)
-                        .mountName(mountName)
-                        .status(cachedToolResult.isSuccess() ? "COMPLETED" : "FAILED")
-                        .inputSummary(summarizeToolPayload(toolInput, 400))
-                        .outputSummary(summarizeToolPayload(cachedToolResult.getOutput(), 600))
-                        .errorMessage(cachedToolResult.getErrorMessage())
-                        .durationMs(0L)
-                        .timestamp(System.currentTimeMillis())
-                        .build());
-                if (thinkingTraceEmitter != null) {
-                    thinkingTraceEmitter.onToolResult(toolName, toolCallId, !cachedToolResult.isSuccess());
+                String outputSummary = summarizeToolPayload(cachedToolResult.getOutput(), 600);
+                String errorMessage = cachedToolResult.getErrorMessage();
+                if (traceEmitter != null) {
+                    traceEmitter.onToolResult(toolName, mountName, toolCallId, inputSummary, outputSummary,
+                            errorMessage, 0L, System.currentTimeMillis(), !cachedToolResult.isSuccess());
                 }
                 if (cachedToolResult.isSuccess()) {
                     toolSuccessCount.incrementAndGet();
                     aiCallLogService.saveMcpCallLog(callLogId, requestId, sessionId, toolCallId, mountName, toolName,
-                            "COMPLETED", summarizeToolPayload(toolInput, 400), summarizeToolPayload(cachedToolResult.getOutput(), 600),
+                            "COMPLETED", inputSummary, outputSummary,
                             null, 0L, userId, tenantId);
                     return cachedToolResult.getOutput();
                 }
                 toolFailureCount.incrementAndGet();
                 aiCallLogService.saveMcpCallLog(callLogId, requestId, sessionId, toolCallId, mountName, toolName,
-                        "FAILED", summarizeToolPayload(toolInput, 400), null, cachedToolResult.getErrorMessage(),
+                        "FAILED", inputSummary, null, errorMessage,
                         0L, userId, tenantId);
-                throw new CoolException(cachedToolResult.getErrorMessage());
+                throw new CoolException(errorMessage);
             }
-            if (thinkingTraceEmitter != null) {
-                thinkingTraceEmitter.onToolStart(toolName, toolCallId);
+            if (traceEmitter != null) {
+                traceEmitter.onToolStart(toolName, mountName, toolCallId, inputSummary, startedAt);
             }
-            aiSseEventPublisher.emitSafely(emitter, "tool_start", AiChatToolEventDto.builder()
-                    .requestId(requestId)
-                    .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;
-                aiSseEventPublisher.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(durationMs)
-                        .timestamp(System.currentTimeMillis())
-                        .build());
-                if (thinkingTraceEmitter != null) {
-                    thinkingTraceEmitter.onToolResult(toolName, toolCallId, false);
+                String outputSummary = summarizeToolPayload(output, 600);
+                if (traceEmitter != null) {
+                    traceEmitter.onToolResult(toolName, mountName, toolCallId, inputSummary, outputSummary,
+                            null, durationMs, System.currentTimeMillis(), false);
                 }
                 aiToolResultStore.cacheToolResult(tenantId, requestId, toolName, toolInput, true, output, null);
                 toolSuccessCount.incrementAndGet();
                 aiCallLogService.saveMcpCallLog(callLogId, requestId, sessionId, toolCallId, mountName, toolName,
-                        "COMPLETED", summarizeToolPayload(toolInput, 400), summarizeToolPayload(output, 600),
+                        "COMPLETED", inputSummary, outputSummary,
                         null, durationMs, userId, tenantId);
                 return output;
             } catch (RuntimeException e) {
                 long durationMs = System.currentTimeMillis() - startedAt;
-                aiSseEventPublisher.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(durationMs)
-                        .timestamp(System.currentTimeMillis())
-                        .build());
-                if (thinkingTraceEmitter != null) {
-                    thinkingTraceEmitter.onToolResult(toolName, toolCallId, true);
+                String errorMessage = e.getMessage();
+                if (traceEmitter != null) {
+                    traceEmitter.onToolResult(toolName, mountName, toolCallId, inputSummary, null,
+                            errorMessage, durationMs, System.currentTimeMillis(), true);
                 }
-                aiToolResultStore.cacheToolResult(tenantId, requestId, toolName, toolInput, false, null, e.getMessage());
+                aiToolResultStore.cacheToolResult(tenantId, requestId, toolName, toolInput, false, null, errorMessage);
                 toolFailureCount.incrementAndGet();
                 aiCallLogService.saveMcpCallLog(callLogId, requestId, sessionId, toolCallId, mountName, toolName,
-                        "FAILED", summarizeToolPayload(toolInput, 400), null, e.getMessage(),
+                        "FAILED", inputSummary, null, errorMessage,
                         durationMs, userId, tenantId);
                 throw e;
             }
diff --git a/rsf-server/src/test/java/com/vincent/rsf/server/AI/service/impl/chat/AiChatTraceEmitterTest.java b/rsf-server/src/test/java/com/vincent/rsf/server/AI/service/impl/chat/AiChatTraceEmitterTest.java
new file mode 100644
index 0000000..53b3dfe
--- /dev/null
+++ b/rsf-server/src/test/java/com/vincent/rsf/server/AI/service/impl/chat/AiChatTraceEmitterTest.java
@@ -0,0 +1,59 @@
+package com.vincent.rsf.server.ai.service.impl.chat;
+
+import com.vincent.rsf.server.ai.dto.AiChatTraceEventDto;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+@ExtendWith(MockitoExtension.class)
+class AiChatTraceEmitterTest {
+
+    @Mock
+    private AiSseEventPublisher aiSseEventPublisher;
+
+    @Captor
+    private ArgumentCaptor<AiChatTraceEventDto> payloadCaptor;
+
+    @Test
+    void shouldReuseTraceIdentityForLogicalTraceCards() {
+        AiChatTraceEmitter traceEmitter = new AiChatTraceEmitter(
+                aiSseEventPublisher,
+                new SseEmitter(1000L),
+                "req-1",
+                11L,
+                new AtomicLong(0)
+        );
+
+        traceEmitter.startAnalyze();
+        traceEmitter.onToolStart("inventory.lookup", "builtin-stock", "tool-1", "{\"code\":\"A01\"}", 100L);
+        traceEmitter.onToolResult("inventory.lookup", "builtin-stock", "tool-1", "{\"code\":\"A01\"}",
+                "{\"stock\":12}", null, 64L, 164L, false);
+
+        verify(aiSseEventPublisher, times(4)).emitSafely(any(), eq("trace"), payloadCaptor.capture());
+
+        List<AiChatTraceEventDto> payloads = payloadCaptor.getAllValues();
+        assertThat(payloads).extracting(AiChatTraceEventDto::getSequence)
+                .containsExactly(1L, 1L, 2L, 2L);
+        assertThat(payloads).extracting(AiChatTraceEventDto::getTraceId)
+                .containsExactly("req-1-thinking-ANALYZE", "req-1-thinking-ANALYZE", "tool-1", "tool-1");
+        assertThat(payloads).extracting(AiChatTraceEventDto::getTraceType)
+                .containsExactly("thinking", "thinking", "tool", "tool");
+        assertThat(payloads.get(1).getStatus()).isEqualTo("COMPLETED");
+        assertThat(payloads.get(3).getStatus()).isEqualTo("COMPLETED");
+        assertThat(payloads.get(3).getToolCallId()).isEqualTo("tool-1");
+        assertThat(payloads.get(3).getInputSummary()).isEqualTo("{\"code\":\"A01\"}");
+    }
+}
diff --git a/rsf-server/src/test/java/com/vincent/rsf/server/AI/service/impl/chat/AiToolObservationServiceTest.java b/rsf-server/src/test/java/com/vincent/rsf/server/AI/service/impl/chat/AiToolObservationServiceTest.java
new file mode 100644
index 0000000..5c0f320
--- /dev/null
+++ b/rsf-server/src/test/java/com/vincent/rsf/server/AI/service/impl/chat/AiToolObservationServiceTest.java
@@ -0,0 +1,70 @@
+package com.vincent.rsf.server.ai.service.impl.chat;
+
+import com.vincent.rsf.server.ai.service.AiCallLogService;
+import com.vincent.rsf.server.ai.service.MountedToolCallback;
+import com.vincent.rsf.server.ai.store.AiCachedToolResult;
+import com.vincent.rsf.server.ai.store.AiToolResultStore;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.definition.ToolDefinition;
+
+import java.util.concurrent.atomic.AtomicLong;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class AiToolObservationServiceTest {
+
+    @Mock
+    private AiToolResultStore aiToolResultStore;
+    @Mock
+    private AiCallLogService aiCallLogService;
+    @Mock
+    private AiChatTraceEmitter traceEmitter;
+    @Mock
+    private MountedToolCallback mountedToolCallback;
+    @Mock
+    private ToolDefinition toolDefinition;
+
+    @Test
+    void shouldSkipStartTraceWhenToolResultComesFromCache() {
+        AiToolObservationService aiToolObservationService = new AiToolObservationService(aiToolResultStore, aiCallLogService);
+        when(mountedToolCallback.getToolDefinition()).thenReturn(toolDefinition);
+        when(toolDefinition.name()).thenReturn("inventory.lookup");
+        when(mountedToolCallback.getMountName()).thenReturn("builtin-stock");
+        when(aiToolResultStore.getToolResult(1L, "req-1", "inventory.lookup", "{\"code\":\"A01\"}"))
+                .thenReturn(AiCachedToolResult.builder()
+                        .success(true)
+                        .output("{\"stock\":12}")
+                        .build());
+
+        ToolCallback[] callbacks = aiToolObservationService.wrapToolCallbacks(
+                new ToolCallback[]{mountedToolCallback},
+                "req-1",
+                11L,
+                new AtomicLong(0),
+                new AtomicLong(0),
+                new AtomicLong(0),
+                21L,
+                31L,
+                1L,
+                traceEmitter
+        );
+
+        String output = callbacks[0].call("{\"code\":\"A01\"}");
+
+        assertThat(output).isEqualTo("{\"stock\":12}");
+        verify(traceEmitter, never()).onToolStart(eq("inventory.lookup"), eq("builtin-stock"), eq("req-1-tool-1"), eq("{\"code\":\"A01\"}"), anyLong());
+        verify(traceEmitter).onToolResult(eq("inventory.lookup"), eq("builtin-stock"), eq("req-1-tool-1"),
+                eq("{\"code\":\"A01\"}"), eq("{\"stock\":12}"), eq(null), eq(0L), anyLong(), eq(false));
+        verify(mountedToolCallback, never()).call("{\"code\":\"A01\"}");
+    }
+}

--
Gitblit v1.9.1