#
zhou zhou
3 天以前 66d766c88ec5d1ab4715fd9f2c22ce42b459d957
#
7个文件已修改
524 ■■■■■ 已修改文件
rsf-design/src/components/core/layouts/art-chat-window/index.vue 388 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/locales/langs/en.json 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/locales/langs/zh.json 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-design/src/plugins/iconify.js 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiChatFailureHandler.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/main/java/com/vincent/rsf/server/ai/service/impl/chat/AiPromptMessageBuilder.java 10 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rsf-server/src/test/java/com/vincent/rsf/server/AI/service/impl/chat/AiPromptMessageBuilderTest.java 61 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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>
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",
rsf-design/src/locales/langs/zh.json
@@ -618,6 +618,9 @@
      "renameAction": "重命名会话",
      "deleteAction": "删除会话",
      "activityTrace": "思维链与工具轨迹",
      "traceExpand": "展开思维链",
      "traceCollapse": "收起思维链",
      "noActivityTrace": "思维链和工具轨迹会在这里按阶段展开。",
      "thinkingEmpty": "正在整理当前阶段信息...",
      "thinkingStatusStarted": "已开始",
      "thinkingStatusUpdated": "进行中",
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) {
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) {
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++) {
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) {