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