From 66d766c88ec5d1ab4715fd9f2c22ce42b459d957 Mon Sep 17 00:00:00 2001
From: zhou zhou <3272660260@qq.com>
Date: 星期四, 02 四月 2026 18:32:20 +0800
Subject: [PATCH] #

---
 rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiChatFailureHandler.java       |   37 +++
 rsf-design/src/components/core/layouts/art-chat-window/index.vue                                     |  388 ++++++++++++++++++++++++++----------------
 rsf-design/src/locales/langs/en.json                                                                 |    3 
 rsf-design/src/plugins/iconify.js                                                                    |   22 ++
 rsf-design/src/locales/langs/zh.json                                                                 |    3 
 rsf-server/src/test/java/com/vincent/rsf/server/AI/service/impl/chat/AiPromptMessageBuilderTest.java |   61 +++---
 rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiPromptMessageBuilder.java     |   10 
 7 files changed, 337 insertions(+), 187 deletions(-)

diff --git a/rsf-design/src/components/core/layouts/art-chat-window/index.vue b/rsf-design/src/components/core/layouts/art-chat-window/index.vue
index 2304dc4..3d40c28 100644
--- a/rsf-design/src/components/core/layouts/art-chat-window/index.vue
+++ b/rsf-design/src/components/core/layouts/art-chat-window/index.vue
@@ -208,173 +208,214 @@
             class="rounded-2xl"
           />
 
-          <div v-if="traceEvents.length" class="rounded-3xl bg-[var(--art-main-bg-color)] px-5 py-4 shadow-[0_12px_36px_rgba(15,23,42,0.05)] ring-1 ring-[var(--el-border-color-lighter)]">
-            <div class="mb-3 flex items-center justify-between gap-3">
-              <div class="text-sm font-semibold text-[var(--art-gray-900)]">{{ $t('ai.drawer.activityTrace') }}</div>
-              <ElTag effect="plain" round>{{ traceEvents.length }}</ElTag>
-            </div>
-            <div class="space-y-3">
+          <div class="min-h-0 flex-1 ai-chat-workspace" :class="{ 'ai-chat-workspace--trace-collapsed': !tracePanelExpanded }">
+            <div class="flex min-h-0 flex-col ai-chat-trace-column" :class="{ 'ai-chat-trace-column--collapsed': !tracePanelExpanded }">
               <div
-                v-for="item in traceEvents"
-                :key="item.traceId"
-                class="rounded-2xl bg-g-100/55 px-4 py-3 ring-1 ring-[var(--el-border-color-extra-light)]"
+                v-if="tracePanelExpanded"
+                class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-3xl bg-[var(--art-main-bg-color)] shadow-[0_12px_36px_rgba(15,23,42,0.05)] ring-1 ring-[var(--el-border-color-lighter)]"
               >
-                <div class="flex flex-wrap items-center justify-between gap-3">
-                  <div class="min-w-0">
-                    <div class="flex items-center gap-2">
-                      <ElTag size="small" :type="item.traceType === 'thinking' ? 'primary' : 'success'" effect="light">
-                        {{ item.traceType === 'thinking' ? $t('ai.drawer.traceTypeThinking') : $t('ai.drawer.traceTypeTool') }}
-                      </ElTag>
-                      <span class="text-sm font-medium text-[var(--art-gray-900)]">
-                        {{ item.toolName || item.title || $t('ai.drawer.unknownTool') }}
-                      </span>
-                    </div>
-                    <div class="mt-1 text-xs text-[var(--art-gray-500)]">
-                      {{ item.traceType === 'thinking' ? getThinkingStatusLabel(item.status) : getToolStatusLabel(item.status) }}
-                    </div>
-                  </div>
-
-                  <ElButton
-                    v-if="item.traceType !== 'thinking'"
-                    text
-                    size="small"
-                    @click="toggleTraceEventExpanded(item.traceId)"
-                  >
-                    {{ $t(expandedTraceIds.includes(item.traceId) ? 'ai.drawer.collapseDetail' : 'ai.drawer.viewDetail') }}
-                  </ElButton>
-                </div>
-
-                <div
-                  v-if="item.traceType === 'thinking'"
-                  class="mt-3 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--art-gray-700)]"
-                >
-                  {{ item.content || $t('ai.drawer.thinkingEmpty') }}
-                </div>
-
-                <div
-                  v-else-if="expandedTraceIds.includes(item.traceId)"
-                  class="mt-3 space-y-2 text-sm leading-6 text-[var(--art-gray-700)]"
-                >
-                  <div v-if="item.title">{{ item.title }}</div>
-                  <div v-if="item.inputSummary">{{ $t('ai.drawer.toolInput', { value: item.inputSummary }) }}</div>
-                  <div v-if="item.outputSummary">{{ $t('ai.drawer.toolOutput', { value: item.outputSummary }) }}</div>
-                  <div v-if="item.errorMessage" class="text-[var(--el-color-danger)]">
-                    {{ $t('ai.drawer.toolError', { value: item.errorMessage }) }}
+                <div class="flex items-center justify-between gap-3 border-b border-[var(--el-border-color-extra-light)] px-5 py-4">
+                  <div class="text-sm font-semibold text-[var(--art-gray-900)]">{{ $t('ai.drawer.activityTrace') }}</div>
+                  <div class="flex items-center gap-2">
+                    <ElTag effect="plain" round>{{ traceEvents.length }}</ElTag>
+                    <ElButton text size="small" @click="tracePanelExpanded = false">
+                      {{ $t('ai.drawer.traceCollapse') }}
+                    </ElButton>
                   </div>
                 </div>
-              </div>
-            </div>
-          </div>
 
-          <div class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-3xl bg-[var(--art-main-bg-color)] shadow-[0_12px_36px_rgba(15,23,42,0.05)] ring-1 ring-[var(--el-border-color-lighter)]">
-            <div class="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--el-border-color-extra-light)] px-5 py-4">
-              <div>
-                <div class="text-sm font-semibold text-[var(--art-gray-900)]">{{ currentSessionTitle }}</div>
-                <div class="mt-1 text-xs text-[var(--art-gray-500)]">
-                  {{ usageSummaryText }}
+                <div class="min-h-0 flex-1 overflow-hidden">
+                  <ElScrollbar class="h-full bg-g-100/35 px-4 py-4">
+                    <div v-if="!traceEvents.length" class="rounded-3xl border border-dashed border-[var(--el-border-color)] bg-slate-50/80 px-4 py-8 text-center text-sm text-[var(--art-gray-500)]">
+                      {{ $t('ai.drawer.noActivityTrace') }}
+                    </div>
+
+                    <div v-else class="space-y-3">
+                      <div
+                        v-for="item in traceEvents"
+                        :key="item.traceId"
+                        class="rounded-2xl bg-[var(--art-main-bg-color)] px-4 py-3 ring-1 ring-[var(--el-border-color-extra-light)]"
+                      >
+                        <div class="flex flex-wrap items-center justify-between gap-3">
+                          <div class="min-w-0">
+                            <div class="flex items-center gap-2">
+                              <ElTag size="small" :type="item.traceType === 'thinking' ? 'primary' : 'success'" effect="light">
+                                {{ item.traceType === 'thinking' ? $t('ai.drawer.traceTypeThinking') : $t('ai.drawer.traceTypeTool') }}
+                              </ElTag>
+                              <span class="text-sm font-medium text-[var(--art-gray-900)]">
+                                {{ item.toolName || item.title || $t('ai.drawer.unknownTool') }}
+                              </span>
+                            </div>
+                            <div class="mt-1 text-xs text-[var(--art-gray-500)]">
+                              {{ item.traceType === 'thinking' ? getThinkingStatusLabel(item.status) : getToolStatusLabel(item.status) }}
+                            </div>
+                          </div>
+
+                          <ElButton
+                            v-if="item.traceType !== 'thinking'"
+                            text
+                            size="small"
+                            @click="toggleTraceEventExpanded(item.traceId)"
+                          >
+                            {{ $t(expandedTraceIds.includes(item.traceId) ? 'ai.drawer.collapseDetail' : 'ai.drawer.viewDetail') }}
+                          </ElButton>
+                        </div>
+
+                        <div
+                          v-if="item.traceType === 'thinking'"
+                          class="mt-3 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--art-gray-700)]"
+                        >
+                          {{ item.content || $t('ai.drawer.thinkingEmpty') }}
+                        </div>
+
+                        <div
+                          v-else-if="expandedTraceIds.includes(item.traceId)"
+                          class="mt-3 space-y-2 text-sm leading-6 text-[var(--art-gray-700)]"
+                        >
+                          <div v-if="item.title">{{ item.title }}</div>
+                          <div v-if="item.inputSummary">{{ $t('ai.drawer.toolInput', { value: item.inputSummary }) }}</div>
+                          <div v-if="item.outputSummary">{{ $t('ai.drawer.toolOutput', { value: item.outputSummary }) }}</div>
+                          <div v-if="item.errorMessage" class="text-[var(--el-color-danger)]">
+                            {{ $t('ai.drawer.toolError', { value: item.errorMessage }) }}
+                          </div>
+                        </div>
+                      </div>
+                    </div>
+                  </ElScrollbar>
                 </div>
               </div>
-              <div class="flex flex-wrap gap-2">
-                <ElTag v-if="usage?.totalTokens != null" effect="plain" round>
-                  {{ $t('ai.drawer.tokenMetric', {
-                    prompt: usage?.promptTokens ?? 0,
-                    completion: usage?.completionTokens ?? 0,
-                    total: usage?.totalTokens ?? 0
-                  }) }}
-                </ElTag>
-              </div>
-            </div>
 
-          <ElScrollbar class="min-h-0 flex-1 bg-g-100/35 px-5 py-5">
-            <div class="space-y-5">
-              <div v-if="!messages.length" class="rounded-3xl border border-dashed border-[var(--el-border-color)] bg-slate-50/80 px-4 py-8 text-center text-sm text-[var(--art-gray-500)]">
-                {{ $t('ai.drawer.emptyHint') }}
-              </div>
-
-              <div
-                v-for="(message, index) in messages"
-                :key="`${message.role}-${index}`"
-                class="flex items-end gap-3"
-                :class="message.role === 'user' ? 'justify-end' : 'justify-start'"
+              <button
+                v-else
+                type="button"
+                class="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 rounded-3xl bg-[var(--art-main-bg-color)] px-3 py-5 text-center shadow-[0_12px_36px_rgba(15,23,42,0.05)] ring-1 ring-[var(--el-border-color-lighter)] transition-colors hover:bg-[var(--el-color-primary-light-9)]/60"
+                @click="tracePanelExpanded = true"
               >
-                <div
-                  v-if="message.role !== 'user'"
-                  class="flex size-9 shrink-0 items-center justify-center rounded-2xl bg-[var(--el-color-primary-light-9)] text-[var(--el-color-primary)]"
-                >
-                  <ArtSvgIcon icon="ri:robot-2-line" class="text-base" />
+                <div class="flex size-11 items-center justify-center rounded-2xl bg-[var(--el-color-primary-light-9)] text-[var(--el-color-primary)]">
+                  <ArtSvgIcon icon="ri:sidebar-unfold-line" class="text-lg" />
                 </div>
-
-                <div class="max-w-[82%]">
-                  <div
-                    class="rounded-3xl px-4 py-3 text-sm leading-6 shadow-[0_10px_30px_rgba(15,23,42,0.04)]"
-                    :class="
-                      message.role === 'user'
-                        ? 'rounded-br-xl bg-[var(--el-color-primary)] text-white'
-                        : 'rounded-bl-xl bg-[var(--art-main-bg-color)] text-[var(--art-gray-900)] ring-1 ring-[var(--el-border-color-extra-light)]'
-                    "
-                  >
-                    <MarkdownMessage
-                      v-if="message.role === 'assistant'"
-                      :content="
-                        message.content || (streaming && index === messages.length - 1 ? $t('ai.drawer.thinking') : '')
-                      "
-                    />
-                    <div v-else class="whitespace-pre-wrap break-words">
-                      {{ message.content || '' }}
-                    </div>
-                  </div>
+                <div class="text-xs font-medium leading-5 text-[var(--art-gray-700)] ai-chat-trace-collapsed-label">
+                  {{ $t('ai.drawer.activityTrace') }}
                 </div>
-
-                <div
-                  v-if="message.role === 'user'"
-                  class="flex size-9 shrink-0 items-center justify-center rounded-2xl bg-[var(--el-color-primary)] text-white"
-                >
-                  <ArtSvgIcon icon="ri:user-3-line" class="text-base" />
-                </div>
-              </div>
-
-              <div ref="messagesBottomRef"></div>
-            </div>
-          </ElScrollbar>
-
-          <div class="border-t border-[var(--el-border-color-extra-light)] bg-[var(--art-main-bg-color)] px-5 py-4">
-            <div class="rounded-3xl bg-g-100/45 p-4 ring-1 ring-[var(--el-border-color-lighter)]">
-              <ElInput
-                v-model="input"
-                type="textarea"
-                :autosize="{ minRows: 3, maxRows: 6 }"
-                resize="none"
-                :placeholder="$t('ai.drawer.inputPlaceholder')"
-                @keydown="handleInputKeydown"
-              />
-
-              <div class="mt-3 flex flex-wrap items-center justify-between gap-3">
+                <ElTag effect="plain" round>{{ traceEvents.length }}</ElTag>
                 <div class="text-xs text-[var(--art-gray-500)]">
-                  {{ $t('ai.drawer.inputHotkeyHint') }}
+                  {{ $t('ai.drawer.traceExpand') }}
+                </div>
+              </button>
+            </div>
+
+            <div class="flex min-h-0 flex-1 flex-col gap-4 ai-chat-main-column">
+              <div class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-3xl bg-[var(--art-main-bg-color)] shadow-[0_12px_36px_rgba(15,23,42,0.05)] ring-1 ring-[var(--el-border-color-lighter)]">
+                <div class="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--el-border-color-extra-light)] px-5 py-4">
+                  <div>
+                    <div class="text-sm font-semibold text-[var(--art-gray-900)]">{{ currentSessionTitle }}</div>
+                    <div class="mt-1 text-xs text-[var(--art-gray-500)]">
+                      {{ usageSummaryText }}
+                    </div>
+                  </div>
+                  <div class="flex flex-wrap gap-2">
+                    <ElTag v-if="usage?.totalTokens != null" effect="plain" round>
+                      {{ $t('ai.drawer.tokenMetric', {
+                        prompt: usage?.promptTokens ?? 0,
+                        completion: usage?.completionTokens ?? 0,
+                        total: usage?.totalTokens ?? 0
+                      }) }}
+                    </ElTag>
+                  </div>
                 </div>
 
-                <div class="flex flex-wrap items-center gap-2">
-                  <ElButton text @click="input = ''">{{ $t('ai.drawer.clearInput') }}</ElButton>
-                  <ElButton
-                    v-if="streaming"
-                    type="warning"
-                    plain
-                    @click="stopStream(true)"
-                  >
-                    {{ $t('ai.drawer.stop') }}
-                  </ElButton>
-                  <ElButton
-                    v-else
-                    type="primary"
-                    @click="handleSend"
-                  >
-                    <ArtSvgIcon icon="ri:send-plane-2-line" class="mr-1 text-sm" />
-                    {{ $t('ai.drawer.send') }}
-                  </ElButton>
+                <ElScrollbar class="min-h-0 flex-1 bg-g-100/35 px-5 py-5">
+                  <div class="space-y-5">
+                    <div v-if="!messages.length" class="rounded-3xl border border-dashed border-[var(--el-border-color)] bg-slate-50/80 px-4 py-8 text-center text-sm text-[var(--art-gray-500)]">
+                      {{ $t('ai.drawer.emptyHint') }}
+                    </div>
+
+                    <div
+                      v-for="(message, index) in messages"
+                      :key="`${message.role}-${index}`"
+                      class="flex items-end gap-3"
+                      :class="message.role === 'user' ? 'justify-end' : 'justify-start'"
+                    >
+                      <div
+                        v-if="message.role !== 'user'"
+                        class="flex size-9 shrink-0 items-center justify-center rounded-2xl bg-[var(--el-color-primary-light-9)] text-[var(--el-color-primary)]"
+                      >
+                        <ArtSvgIcon icon="ri:robot-2-line" class="text-base" />
+                      </div>
+
+                      <div class="max-w-[82%]">
+                        <div
+                          class="rounded-3xl px-4 py-3 text-sm leading-6 shadow-[0_10px_30px_rgba(15,23,42,0.04)]"
+                          :class="
+                            message.role === 'user'
+                              ? 'rounded-br-xl bg-[var(--el-color-primary)] text-white'
+                              : 'rounded-bl-xl bg-[var(--art-main-bg-color)] text-[var(--art-gray-900)] ring-1 ring-[var(--el-border-color-extra-light)]'
+                          "
+                        >
+                          <MarkdownMessage
+                            v-if="message.role === 'assistant'"
+                            :content="
+                              message.content || (streaming && index === messages.length - 1 ? $t('ai.drawer.thinking') : '')
+                            "
+                          />
+                          <div v-else class="whitespace-pre-wrap break-words">
+                            {{ message.content || '' }}
+                          </div>
+                        </div>
+                      </div>
+
+                      <div
+                        v-if="message.role === 'user'"
+                        class="flex size-9 shrink-0 items-center justify-center rounded-2xl bg-[var(--el-color-primary)] text-white"
+                      >
+                        <ArtSvgIcon icon="ri:user-3-line" class="text-base" />
+                      </div>
+                    </div>
+
+                    <div ref="messagesBottomRef"></div>
+                  </div>
+                </ElScrollbar>
+              </div>
+
+              <div class="rounded-3xl bg-[var(--art-main-bg-color)] px-5 py-4 shadow-[0_12px_36px_rgba(15,23,42,0.05)] ring-1 ring-[var(--el-border-color-lighter)]">
+                <div class="rounded-3xl bg-g-100/45 p-4 ring-1 ring-[var(--el-border-color-lighter)]">
+                  <ElInput
+                    v-model="input"
+                    type="textarea"
+                    :autosize="{ minRows: 3, maxRows: 6 }"
+                    resize="none"
+                    :placeholder="$t('ai.drawer.inputPlaceholder')"
+                    @keydown="handleInputKeydown"
+                  />
+
+                  <div class="mt-3 flex flex-wrap items-center justify-between gap-3">
+                    <div class="text-xs text-[var(--art-gray-500)]">
+                      {{ $t('ai.drawer.inputHotkeyHint') }}
+                    </div>
+
+                    <div class="flex flex-wrap items-center gap-2">
+                      <ElButton text @click="input = ''">{{ $t('ai.drawer.clearInput') }}</ElButton>
+                      <ElButton
+                        v-if="streaming"
+                        type="warning"
+                        plain
+                        @click="stopStream(true)"
+                      >
+                        {{ $t('ai.drawer.stop') }}
+                      </ElButton>
+                      <ElButton
+                        v-else
+                        type="primary"
+                        @click="handleSend"
+                      >
+                        <ArtSvgIcon icon="ri:send-plane-2-line" class="mr-1 text-sm" />
+                        {{ $t('ai.drawer.send') }}
+                      </ElButton>
+                    </div>
+                  </div>
                 </div>
               </div>
             </div>
-          </div>
           </div>
         </section>
       </div>
@@ -449,6 +490,7 @@
   const loadingRuntime = ref(false)
   const streaming = ref(false)
   const runtimePanelExpanded = ref(false)
+  const tracePanelExpanded = ref(false)
   const messagesBottomRef = ref(null)
   const renameDialog = reactive({
     open: false,
@@ -542,6 +584,7 @@
   watch(isDrawerVisible, async (visible) => {
     if (visible) {
       runtimePanelExpanded.value = false
+      tracePanelExpanded.value = false
       await initializeDrawer()
       scrollMessagesToBottom()
       return
@@ -1010,6 +1053,32 @@
     width: 320px;
   }
 
+  .ai-chat-workspace {
+    display: flex;
+    gap: 16px;
+  }
+
+  .ai-chat-trace-column {
+    width: 360px;
+    flex-shrink: 0;
+    transition:
+      width 0.2s ease,
+      min-width 0.2s ease;
+  }
+
+  .ai-chat-trace-column--collapsed {
+    width: 88px;
+  }
+
+  .ai-chat-trace-collapsed-label {
+    writing-mode: vertical-rl;
+    text-orientation: mixed;
+  }
+
+  .ai-chat-main-column {
+    min-width: 0;
+  }
+
   :deep(.ai-chat-drawer .el-drawer__body) {
     padding: 0;
   }
@@ -1055,5 +1124,24 @@
       border-right: 0;
       border-bottom: 1px solid var(--art-border-color);
     }
+
+    .ai-chat-workspace {
+      flex-direction: column;
+    }
+
+    .ai-chat-trace-column {
+      width: 100%;
+      min-height: 260px;
+    }
+
+    .ai-chat-trace-column--collapsed {
+      width: 100%;
+      min-height: auto;
+    }
+
+    .ai-chat-trace-collapsed-label {
+      writing-mode: horizontal-tb;
+      text-orientation: initial;
+    }
   }
 </style>
diff --git a/rsf-design/src/locales/langs/en.json b/rsf-design/src/locales/langs/en.json
index f6ae9c9..caac110 100644
--- a/rsf-design/src/locales/langs/en.json
+++ b/rsf-design/src/locales/langs/en.json
@@ -616,6 +616,9 @@
       "renameAction": "Rename session",
       "deleteAction": "Delete session",
       "activityTrace": "Thinking & Tool Trace",
+      "traceExpand": "Show Trace",
+      "traceCollapse": "Hide Trace",
+      "noActivityTrace": "Thinking steps and tool traces will appear here by stage.",
       "thinkingEmpty": "Organizing the current stage information...",
       "thinkingStatusStarted": "Started",
       "thinkingStatusUpdated": "In Progress",
diff --git a/rsf-design/src/locales/langs/zh.json b/rsf-design/src/locales/langs/zh.json
index 4133832..3995d16 100644
--- a/rsf-design/src/locales/langs/zh.json
+++ b/rsf-design/src/locales/langs/zh.json
@@ -618,6 +618,9 @@
       "renameAction": "閲嶅懡鍚嶄細璇�",
       "deleteAction": "鍒犻櫎浼氳瘽",
       "activityTrace": "鎬濈淮閾句笌宸ュ叿杞ㄨ抗",
+      "traceExpand": "灞曞紑鎬濈淮閾�",
+      "traceCollapse": "鏀惰捣鎬濈淮閾�",
+      "noActivityTrace": "鎬濈淮閾惧拰宸ュ叿杞ㄨ抗浼氬湪杩欓噷鎸夐樁娈靛睍寮�銆�",
       "thinkingEmpty": "姝e湪鏁寸悊褰撳墠闃舵淇℃伅...",
       "thinkingStatusStarted": "宸插紑濮�",
       "thinkingStatusUpdated": "杩涜涓�",
diff --git a/rsf-design/src/plugins/iconify.js b/rsf-design/src/plugins/iconify.js
index 5158b16..1895fc7 100644
--- a/rsf-design/src/plugins/iconify.js
+++ b/rsf-design/src/plugins/iconify.js
@@ -1,9 +1,27 @@
 import { addCollection } from '@iconify/vue/offline'
-import { LOCAL_ICON_COLLECTIONS } from './iconify.collections.js'
+import { icons as fluentIcons } from '@iconify-json/fluent'
+import { icons as iconParkOutlineIcons } from '@iconify-json/icon-park-outline'
+import { icons as iconamoonIcons } from '@iconify-json/iconamoon'
+import { icons as ixIcons } from '@iconify-json/ix'
+import { icons as lineMdIcons } from '@iconify-json/line-md'
+import { icons as remixIcons } from '@iconify-json/ri'
+import { icons as svgSpinnersIcons } from '@iconify-json/svg-spinners'
+import { icons as systemUiconsIcons } from '@iconify-json/system-uicons'
+import { icons as vaadinIcons } from '@iconify-json/vaadin'
 
 let iconCollectionsRegistered = false
 
-export { LOCAL_ICON_COLLECTIONS }
+export const LOCAL_ICON_COLLECTIONS = Object.freeze({
+  fluent: fluentIcons,
+  'icon-park-outline': iconParkOutlineIcons,
+  iconamoon: iconamoonIcons,
+  ix: ixIcons,
+  'line-md': lineMdIcons,
+  ri: remixIcons,
+  'svg-spinners': svgSpinnersIcons,
+  'system-uicons': systemUiconsIcons,
+  vaadin: vaadinIcons
+})
 
 export function registerLocalIconCollections() {
   if (iconCollectionsRegistered) {
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 d9f9d91..133aa27 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
@@ -8,6 +8,8 @@
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.reactive.function.client.WebClientResponseException;
 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
 
 import java.time.Instant;
@@ -22,7 +24,7 @@
     private final AiStreamStateStore aiStreamStateStore;
 
     public AiChatException buildAiException(String code, AiErrorCategory category, String stage, String message, Throwable cause) {
-        return new AiChatException(code, category, stage, message, cause);
+        return new AiChatException(code, category, stage, resolveExceptionMessage(message, cause), cause);
     }
 
     public void handleStreamFailure(SseEmitter emitter, String requestId, Long sessionId, String model, long startedAt,
@@ -50,7 +52,7 @@
                     toolFailureCount
             );
             aiStreamStateStore.markStreamState(requestId, tenantId, userId, sessionId, promptCode, "ABORTED", exception.getMessage());
-            emitter.completeWithError(exception);
+            emitter.complete();
             return;
         }
         log.error("AI chat failed, requestId={}, sessionId={}, category={}, stage={}, message={}",
@@ -81,7 +83,36 @@
                 toolFailureCount
         );
         aiStreamStateStore.markStreamState(requestId, tenantId, userId, sessionId, promptCode, "FAILED", exception.getMessage());
-        emitter.completeWithError(exception);
+        emitter.complete();
+    }
+
+    private String resolveExceptionMessage(String message, Throwable cause) {
+        String upstreamMessage = extractUpstreamResponseBody(cause);
+        if (StringUtils.hasText(upstreamMessage)) {
+            return truncateMessage(upstreamMessage);
+        }
+        return truncateMessage(message);
+    }
+
+    private String extractUpstreamResponseBody(Throwable throwable) {
+        Throwable current = throwable;
+        while (current != null) {
+            if (current instanceof WebClientResponseException webClientResponseException) {
+                String responseBody = webClientResponseException.getResponseBodyAsString();
+                if (StringUtils.hasText(responseBody)) {
+                    return responseBody.replace('\n', ' ').replace('\r', ' ').trim();
+                }
+            }
+            current = current.getCause();
+        }
+        return null;
+    }
+
+    private String truncateMessage(String message) {
+        if (!StringUtils.hasText(message)) {
+            return message;
+        }
+        return message.length() > 900 ? message.substring(0, 900) : message;
     }
 
     private boolean isClientAbortException(Throwable throwable) {
diff --git a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiPromptMessageBuilder.java b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiPromptMessageBuilder.java
index 0bf45b3..e29c67b 100644
--- a/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiPromptMessageBuilder.java
+++ b/rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiPromptMessageBuilder.java
@@ -26,14 +26,18 @@
             throw new CoolException("瀵硅瘽娑堟伅涓嶈兘涓虹┖");
         }
         List<Message> messages = new ArrayList<>();
+        List<String> systemSections = new ArrayList<>();
         if (StringUtils.hasText(aiPrompt.getSystemPrompt())) {
-            messages.add(new SystemMessage(aiPrompt.getSystemPrompt()));
+            systemSections.add(aiPrompt.getSystemPrompt());
         }
         if (memory != null && StringUtils.hasText(memory.getMemorySummary())) {
-            messages.add(new SystemMessage("鍘嗗彶鎽樿:\n" + memory.getMemorySummary()));
+            systemSections.add("鍘嗗彶鎽樿:\n" + memory.getMemorySummary());
         }
         if (memory != null && StringUtils.hasText(memory.getMemoryFacts())) {
-            messages.add(new SystemMessage("鍏抽敭浜嬪疄:\n" + memory.getMemoryFacts()));
+            systemSections.add("鍏抽敭浜嬪疄:\n" + memory.getMemoryFacts());
+        }
+        if (!systemSections.isEmpty()) {
+            messages.add(new SystemMessage(String.join("\n\n", systemSections)));
         }
         int lastUserIndex = -1;
         for (int i = 0; i < sourceMessages.size(); i++) {
diff --git a/rsf-server/src/test/java/com/vincent/rsf/server/AI/service/impl/chat/AiPromptMessageBuilderTest.java b/rsf-server/src/test/java/com/vincent/rsf/server/AI/service/impl/chat/AiPromptMessageBuilderTest.java
index 41cd3c0..ee0b87b 100644
--- a/rsf-server/src/test/java/com/vincent/rsf/server/AI/service/impl/chat/AiPromptMessageBuilderTest.java
+++ b/rsf-server/src/test/java/com/vincent/rsf/server/AI/service/impl/chat/AiPromptMessageBuilderTest.java
@@ -4,59 +4,62 @@
 import com.vincent.rsf.server.ai.dto.AiChatMessageDto;
 import com.vincent.rsf.server.ai.entity.AiPrompt;
 import org.junit.jupiter.api.Test;
+import org.springframework.ai.chat.messages.AssistantMessage;
 import org.springframework.ai.chat.messages.Message;
 import org.springframework.ai.chat.messages.SystemMessage;
 import org.springframework.ai.chat.messages.UserMessage;
 
 import java.util.List;
-import java.util.Map;
 
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.assertj.core.api.Assertions.assertThat;
 
 class AiPromptMessageBuilderTest {
 
-    private final AiPromptMessageBuilder builder = new AiPromptMessageBuilder();
+    private final AiPromptMessageBuilder aiPromptMessageBuilder = new AiPromptMessageBuilder();
 
     @Test
-    void shouldBuildPromptMessagesInExpectedOrderAndRenderLastUserPrompt() {
+    void shouldMergeAllSystemContextIntoSingleLeadingSystemMessage() {
         AiChatMemoryDto memory = AiChatMemoryDto.builder()
-                .memorySummary("summary")
-                .memoryFacts("facts")
+                .memorySummary("杩欐槸鎽樿")
+                .memoryFacts("杩欐槸浜嬪疄")
                 .build();
         AiPrompt prompt = new AiPrompt()
-                .setSystemPrompt("system")
-                .setUserPromptTemplate("鐢ㄦ埛闂: {{input}} | 浠撳簱: {{warehouse}}");
-        List<AiChatMessageDto> messages = List.of(
-                message("user", "old question"),
-                message("assistant", "old answer"),
-                message("user", "latest question")
+                .setSystemPrompt("浣犳槸鍔╂墜")
+                .setUserPromptTemplate("璇峰洖绛旓細{{input}}");
+
+        List<Message> messages = aiPromptMessageBuilder.buildPromptMessages(
+                memory,
+                List.of(
+                        message("user", "绗竴闂�"),
+                        message("assistant", "绗竴绛�"),
+                        message("user", "绗簩闂�")
+                ),
+                prompt,
+                null
         );
 
-        List<Message> built = builder.buildPromptMessages(memory, messages, prompt, Map.of("warehouse", "WH1"));
-
-        assertEquals(6, built.size());
-        assertInstanceOf(SystemMessage.class, built.get(0));
-        assertEquals("system", built.get(0).getText());
-        assertEquals("鍘嗗彶鎽樿:\nsummary", built.get(1).getText());
-        assertEquals("鍏抽敭浜嬪疄:\nfacts", built.get(2).getText());
-        assertInstanceOf(UserMessage.class, built.get(3));
-        assertEquals("old question", built.get(3).getText());
-        assertEquals("old answer", built.get(4).getText());
-        assertInstanceOf(UserMessage.class, built.get(5));
-        assertEquals("鐢ㄦ埛闂: latest question | 浠撳簱: WH1", built.get(5).getText());
+        assertThat(messages).hasSize(4);
+        assertThat(messages.get(0)).isInstanceOf(SystemMessage.class);
+        assertThat(messages.get(1)).isInstanceOf(UserMessage.class);
+        assertThat(messages.get(2)).isInstanceOf(AssistantMessage.class);
+        assertThat(messages.get(3)).isInstanceOf(UserMessage.class);
+        assertThat(((SystemMessage) messages.get(0)).getText())
+                .contains("浣犳槸鍔╂墜")
+                .contains("鍘嗗彶鎽樿:\n杩欐槸鎽樿")
+                .contains("鍏抽敭浜嬪疄:\n杩欐槸浜嬪疄");
+        assertThat(((UserMessage) messages.get(3)).getText()).isEqualTo("璇峰洖绛旓細绗簩闂�");
     }
 
     @Test
     void shouldMergePersistedAndMemoryMessages() {
-        List<AiChatMessageDto> merged = builder.mergeMessages(
+        List<AiChatMessageDto> merged = aiPromptMessageBuilder.mergeMessages(
                 List.of(message("user", "persisted")),
                 List.of(message("assistant", "memory"))
         );
 
-        assertEquals(2, merged.size());
-        assertEquals("persisted", merged.get(0).getContent());
-        assertEquals("memory", merged.get(1).getContent());
+        assertThat(merged).hasSize(2);
+        assertThat(merged.get(0).getContent()).isEqualTo("persisted");
+        assertThat(merged.get(1).getContent()).isEqualTo("memory");
     }
 
     private AiChatMessageDto message(String role, String content) {

--
Gitblit v1.9.1