| | |
| | | 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"> |
| | |
| | | </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> |
| | |
| | | <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" |
| | |
| | | <ArtSvgIcon icon="ri:send-plane-2-line" class="mr-1 text-sm" /> |
| | | {{ $t('ai.drawer.send') }} |
| | | </ElButton> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | |
| | | 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, |
| | |
| | | watch(isDrawerVisible, async (visible) => { |
| | | if (visible) { |
| | | runtimePanelExpanded.value = false |
| | | tracePanelExpanded.value = false |
| | | await initializeDrawer() |
| | | scrollMessagesToBottom() |
| | | return |
| | |
| | | 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; |
| | | } |
| | |
| | | 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> |
| | |
| | | "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", |
| | |
| | | "renameAction": "重命名会话", |
| | | "deleteAction": "删除会话", |
| | | "activityTrace": "思维链与工具轨迹", |
| | | "traceExpand": "展开思维链", |
| | | "traceCollapse": "收起思维链", |
| | | "noActivityTrace": "思维链和工具轨迹会在这里按阶段展开。", |
| | | "thinkingEmpty": "正在整理当前阶段信息...", |
| | | "thinkingStatusStarted": "已开始", |
| | | "thinkingStatusUpdated": "进行中", |
| | |
| | | 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) { |
| | |
| | | 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; |
| | |
| | | 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, |
| | |
| | | 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={}", |
| | |
| | | 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) { |
| | |
| | | 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++) { |
| | |
| | | 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) { |