#
zhou zhou
3 天以前 66d766c88ec5d1ab4715fd9f2c22ce42b459d957
#
7个文件已修改
234 ■■■■ 已修改文件
rsf-design/src/components/core/layouts/art-chat-window/index.vue 98 ●●●●● 补丁 | 查看 | 原始文档 | 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,16 +208,33 @@
            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="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-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 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 class="space-y-3">
                </div>
                <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-g-100/55 px-4 py-3 ring-1 ring-[var(--el-border-color-extra-light)]"
                        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">
@@ -264,8 +281,30 @@
                </div>
              </div>
            </div>
                  </ElScrollbar>
                </div>
          </div>
              <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 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="text-xs font-medium leading-5 text-[var(--art-gray-700)] ai-chat-trace-collapsed-label">
                  {{ $t('ai.drawer.activityTrace') }}
                </div>
                <ElTag effect="plain" round>{{ traceEvents.length }}</ElTag>
                <div class="text-xs text-[var(--art-gray-500)]">
                  {{ $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>
@@ -336,8 +375,9 @@
              <div ref="messagesBottomRef"></div>
            </div>
          </ElScrollbar>
              </div>
          <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-[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"
@@ -371,6 +411,7 @@
                    <ArtSvgIcon icon="ri:send-plane-2-line" class="mr-1 text-sm" />
                    {{ $t('ai.drawer.send') }}
                  </ElButton>
                    </div>
                </div>
              </div>
            </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) {