Prechádzať zdrojové kódy

perf:优化会话加载性能

Jerry Liang 2 mesiacov pred
rodič
commit
62725242b0

+ 50 - 4
service/controllers/aiChatControllers.go

@@ -98,9 +98,9 @@ func CreateAiSession(c *fiber.Ctx) error {
 	})
 }
 
-// LoadAiSession 加载历史会话(恢复 AI 上下文)
-// POST /ai/sessions/:id/load
-func LoadAiSession(c *fiber.Ctx) error {
+// SwitchAiSession 切换到历史会话(恢复 AI 上下文,轻量响应)
+// POST /ai/sessions/:id
+func SwitchAiSession(c *fiber.Ctx) error {
 	sessionId := utils.CopyString(c.Params("id"))
 	if sessionId == "" {
 		return Fail(c, "sessionId 不能为空")
@@ -115,10 +115,56 @@ func LoadAiSession(c *fiber.Ctx) error {
 	return Success(c, fiber.Map{
 		"sessionId":      sess.SessionId,
 		"title":          sess.Title,
-		"replayMessages": sess.ReplayMessages,
+		"welcomeMessage": sess.WelcomeMessage,
 	})
 }
 
+// StreamLoadAiSession SSE 流式推送回放消息
+// GET /ai/sessions/:id/load
+func StreamLoadAiSession(c *fiber.Ctx) error {
+	sessionId := utils.CopyString(c.Params("id"))
+	if sessionId == "" {
+		return Fail(c, "sessionId 不能为空")
+	}
+
+	userId := middleware.AuthGetUserName(c)
+
+	// 获取回放消息(会话必须已通过 SwitchAiSession 加载)
+	replayMessages := acpManager.GetReplayMessages(userId)
+	if replayMessages == nil {
+		return Fail(c, "会话未加载,请先调用切换会话接口")
+	}
+
+	// SSE 流式响应
+	c.Set("Content-Type", "text/event-stream")
+	c.Set("Cache-Control", "no-cache")
+	c.Set("Connection", "keep-alive")
+	c.Set("X-Accel-Buffering", "no")
+
+	c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
+		var totalBytes int
+
+		for _, msg := range replayMessages {
+			data, _ := json.Marshal(msg)
+			totalBytes += len(msg.Content)
+			fmt.Fprintf(w, "event: message\ndata: %s\n\n", string(data))
+			w.Flush()
+		}
+
+		// 发送 done 事件,包含统计信息和建议
+		suggestNewSession := totalBytes >= 100*1024 // 100KB 以上建议新建
+		donePayload, _ := json.Marshal(fiber.Map{
+			"sessionId":         sessionId,
+			"totalMessages":     len(replayMessages),
+			"totalBytes":        totalBytes,
+			"suggestNewSession": suggestNewSession,
+		})
+		fmt.Fprintf(w, "event: done\ndata: %s\n\n", string(donePayload))
+		w.Flush()
+	})
+	return nil
+}
+
 // UpdateAiSessionTitle 更新会话标题
 // PATCH /ai/sessions/:id
 func UpdateAiSessionTitle(c *fiber.Ctx) error {

+ 5 - 2
service/routes/aiChatRoutes.go

@@ -16,8 +16,11 @@ func AiChatSetup(api fiber.Router) {
 	// 创建新会话
 	api.Post("/ai/sessions/new", controllers.CreateAiSession)
 
-	// 加载历史会话(恢复 AI 上下文)
-	api.Post("/ai/sessions/:id/load", controllers.LoadAiSession)
+	// 切换到历史会话(恢复 AI 上下文,轻量响应)
+	api.Post("/ai/sessions/:id", controllers.SwitchAiSession)
+
+	// 流式加载会话回放消息(SSE)
+	api.Get("/ai/sessions/:id/load", controllers.StreamLoadAiSession)
 
 	// 更新会话标题
 	api.Patch("/ai/sessions/:id", controllers.UpdateAiSessionTitle)

+ 14 - 1
service/services/aiChatServices.go

@@ -115,7 +115,8 @@ func (m *ACPManager) CreateNewSession(userId string) (*ACPSession, error) {
 	return m.createSession(userId)
 }
 
-// LoadSession 加载历史会话(恢复 AI 上下文)
+// LoadSession 加载历史会话(恢复 AI 上下文,切换会话)
+// 仅切换会话并恢复上下文,不返回回放消息(回放消息通过 SSE 接口单独获取)
 func (m *ACPManager) LoadSession(userId string, sessionId string) (*ACPSession, error) {
 	m.mu.Lock()
 	defer m.mu.Unlock()
@@ -183,6 +184,18 @@ func (m *ACPManager) LoadSession(userId string, sessionId string) (*ACPSession,
 	return sess, nil
 }
 
+// GetReplayMessages 获取指定用户当前活跃会话的回放消息
+// 用于 SSE 流式推送
+func (m *ACPManager) GetReplayMessages(userId string) []acp.ReplayMessage {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+
+	if sess, ok := m.sessions[userId]; ok {
+		return sess.ReplayMessages
+	}
+	return nil
+}
+
 // ============ 会话列表查询 ============
 
 // GetSessionList 获取用户的会话列表(当前活跃 + Redis 历史)

+ 54 - 2
src/layout/header/Actions/AiChat/ChatPanel.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
-import { NInput, NButton, NEmpty, NScrollbar, NPopconfirm, NSpin, NTag } from 'naive-ui'
-import { ref, nextTick, watch, onMounted } from 'vue'
+import { NInput, NButton, NEmpty, NScrollbar, NPopconfirm, NSpin, NTag, NModal, NCard } from 'naive-ui'
+import { ref, nextTick, watch, onMounted, computed } from 'vue'
 import { useAiChat } from './useAiChat'
 import MessageItem from './MessageItem.vue'
 
@@ -13,6 +13,7 @@ const {
   isLoadingSession,
   pendingFiles,
   isUploading,
+  sessionLoadDoneInfo,
   sendMessage,
   cancelChat,
   clearMessages,
@@ -24,6 +25,7 @@ const {
   handleUploadFile,
   removePendingFile,
   renameSession,
+  dismissSessionLoadInfo,
 } = useAiChat()
 
 const inputText = ref('')
@@ -115,6 +117,30 @@ function formatTime(ts: number): string {
   if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`
   return d.toLocaleDateString()
 }
+
+// 会话加载弹窗相关
+const showSessionLoadDialog = computed(() => {
+  return sessionLoadDoneInfo.value !== null && sessionLoadDoneInfo.value.suggestNewSession
+})
+
+const sessionLoadDialogText = computed(() => {
+  if (!sessionLoadDoneInfo.value) return ''
+  const { totalMessages, totalBytes } = sessionLoadDoneInfo.value
+  const sizeKB = Math.round(totalBytes / 1024)
+  if (totalBytes > 500 * 1024) {
+    return `当前会话内容较多(${totalMessages} 条消息,约 ${sizeKB}KB),强烈建议创建新会话以获得更好的体验。`
+  }
+  return `当前会话内容较多(${totalMessages} 条消息,约 ${sizeKB}KB),建议创建新会话。`
+})
+
+function handleContinueSession() {
+  dismissSessionLoadInfo()
+}
+
+function handleCreateNewSessionFromDialog() {
+  dismissSessionLoadInfo()
+  createNewSession()
+}
 </script>
 
 <template>
@@ -304,6 +330,32 @@ function formatTime(ts: number): string {
         </NButton>
       </div>
     </div>
+
+    <!-- 会话加载建议弹窗 -->
+    <NModal :show="showSessionLoadDialog" :mask-closable="false">
+      <NCard
+        style="width: 400px"
+        title="会话提示"
+        :bordered="false"
+        size="small"
+        role="dialog"
+        aria-modal="true"
+      >
+        <p class="text-sm text-neutral-600 dark:text-neutral-300">
+          {{ sessionLoadDialogText }}
+        </p>
+        <template #footer>
+          <div class="flex justify-end gap-2">
+            <NButton size="small" @click="handleContinueSession">
+              继续使用
+            </NButton>
+            <NButton type="primary" size="small" @click="handleCreateNewSessionFromDialog">
+              创建新会话
+            </NButton>
+          </div>
+        </template>
+      </NCard>
+    </NModal>
   </div>
 </template>
 

+ 154 - 23
src/layout/header/Actions/AiChat/useAiChat.ts

@@ -1,11 +1,11 @@
-import { ref } from 'vue'
+import { ref, toRaw } from 'vue'
 import type { ChatMessage, ToolCallInfo, SessionListItem, UploadedFile } from './interface'
 import { useUserStore } from '@/stores/user'
 import { chatDB } from './chatDB'
 import {
   getAiSessionList,
   createAiSession,
-  loadAiSession,
+  switchAiSession,
   deleteAiSession,
   cancelAiChat,
   resolveAiPermission,
@@ -13,6 +13,13 @@ import {
   updateAiSessionTitle,
 } from '@/service/aiChat'
 
+// SSE done 事件数据
+export interface SessionLoadDoneInfo {
+  totalMessages: number
+  totalBytes: number
+  suggestNewSession: boolean
+}
+
 export function useAiChat() {
   const messages = ref<ChatMessage[]>([])
   const isStreaming = ref(false)
@@ -22,6 +29,7 @@ export function useAiChat() {
   const pendingFiles = ref<UploadedFile[]>([])
   const isUploading = ref(false)
   const isLoadingSession = ref(false)
+  const sessionLoadDoneInfo = ref<SessionLoadDoneInfo | null>(null)
 
   const userStore = useUserStore()
 
@@ -79,7 +87,7 @@ export function useAiChat() {
         currentSessionId.value = latest.sessionId
         await loadLocalMessages(latest.sessionId)
         // 后台恢复 Kiro 上下文
-        await loadAiSession(latest.sessionId)
+        await switchAiSession(latest.sessionId)
         await fetchSessionList()
         return
       }
@@ -125,6 +133,7 @@ export function useAiChat() {
   async function loadSession(sessionId: string) {
     if (sessionId === currentSessionId.value) return
     isLoadingSession.value = true
+    sessionLoadDoneInfo.value = null
 
     try {
       // 保存当前会话消息到 IndexedDB
@@ -139,31 +148,20 @@ export function useAiChat() {
         messages.value = cached.messages
         welcomeMessage.value = ''
 
-        // 调用 session/load 恢复 Kiro 上下文,等待完成后再刷新列表
-        await loadAiSession(sessionId)
+        // 调用 switchAiSession 恢复 Kiro 上下文
+        await switchAiSession(sessionId)
         await fetchSessionList()
       }
       else {
-        // 无缓存:调用 session/load 获取回放消息
-        const data = await loadAiSession(sessionId)
+        // 无缓存:先切换会话恢复上下文
+        const data = await switchAiSession(sessionId)
         if (data?.sessionId) {
           currentSessionId.value = data.sessionId
           welcomeMessage.value = ''
+          messages.value = []
 
-          // 将回放消息转为 ChatMessage 格式
-          if ((data as any).replayMessages && (data as any).replayMessages.length > 0) {
-            messages.value = (data as any).replayMessages.map((m: any, i: number) => ({
-              id: m.messageId || `replay_${i}`,
-              role: m.role,
-              content: stripContextPrefix(m.content, m.role),
-              timestamp: Date.now() - ((data as any).replayMessages.length - i) * 1000,
-            }))
-            // 保存到缓存
-            await saveLocalMessages()
-          }
-          else {
-            messages.value = []
-          }
+          // 通过 SSE 流式加载回放消息
+          await streamLoadReplayMessages(sessionId)
 
           await fetchSessionList()
         }
@@ -177,6 +175,134 @@ export function useAiChat() {
     }
   }
 
+  // SSE 流式加载回放消息
+  async function streamLoadReplayMessages(sessionId: string): Promise<void> {
+    const url = `${getBaseUrl()}/ai/sessions/${sessionId}/load`
+
+    const response = await fetch(url, {
+      method: 'GET',
+      headers: {
+        Authorization: `Bearer ${userStore.token}`,
+      },
+    })
+
+    if (!response.ok) {
+      throw new Error(`SSE 加载失败: ${response.status}`)
+    }
+
+    const reader = response.body?.getReader()
+    if (!reader) {
+      throw new Error('无法读取响应流')
+    }
+
+    const decoder = new TextDecoder()
+    let buffer = ''
+    let messageIndex = 0
+
+    while (true) {
+      const { done, value } = await reader.read()
+      if (done) break
+
+      buffer += decoder.decode(value, { stream: true })
+
+      // 按 \n\n 分割 SSE 事件
+      const parts = buffer.split('\n\n')
+      buffer = parts.pop() || ''
+
+      for (const part of parts) {
+        if (!part.trim()) continue
+
+        const lines = part.split('\n')
+        let eventType = ''
+        let data = ''
+
+        for (const line of lines) {
+          if (line.startsWith('event: ')) {
+            eventType = line.slice(7)
+          } else if (line.startsWith('data: ')) {
+            data = line.slice(6)
+          }
+        }
+
+        if (!data) continue
+
+        try {
+          const payload = JSON.parse(data)
+
+          if (eventType === 'message') {
+            // 逐条添加回放消息
+            const chatMsg: ChatMessage = {
+              id: payload.messageId || `replay_${messageIndex}`,
+              role: payload.role,
+              content: stripContextPrefix(payload.content, payload.role),
+              timestamp: Date.now() - 1000 + messageIndex,
+            }
+            messages.value.push(chatMsg)
+            messageIndex++
+          }
+          else if (eventType === 'done') {
+            // done 事件:保存统计信息,用于弹窗判断
+            sessionLoadDoneInfo.value = {
+              totalMessages: payload.totalMessages,
+              totalBytes: payload.totalBytes,
+              suggestNewSession: payload.suggestNewSession,
+            }
+            // 保存到 IndexedDB 缓存
+            if (messages.value.length > 0) {
+              await saveLocalMessages()
+            }
+          }
+        }
+        catch {
+          // 非 JSON 数据忽略
+        }
+      }
+    }
+
+    // 处理剩余 buffer
+    if (buffer.trim()) {
+      const lines = buffer.split('\n')
+      let eventType = ''
+      let data = ''
+
+      for (const line of lines) {
+        if (line.startsWith('event: ')) {
+          eventType = line.slice(7)
+        } else if (line.startsWith('data: ')) {
+          data = line.slice(6)
+        }
+      }
+
+      if (data) {
+        try {
+          const payload = JSON.parse(data)
+          if (eventType === 'message') {
+            const chatMsg: ChatMessage = {
+              id: payload.messageId || `replay_${messageIndex}`,
+              role: payload.role,
+              content: stripContextPrefix(payload.content, payload.role),
+              timestamp: Date.now() - 1000 + messageIndex,
+            }
+            messages.value.push(chatMsg)
+          }
+          else if (eventType === 'done') {
+            sessionLoadDoneInfo.value = {
+              totalMessages: payload.totalMessages,
+              totalBytes: payload.totalBytes,
+              suggestNewSession: payload.suggestNewSession,
+            }
+            if (messages.value.length > 0) {
+              await saveLocalMessages()
+            }
+          }
+        }
+        catch {
+          // ignore
+        }
+      }
+    }
+  }
+
   // 删除会话
   async function deleteSession(sessionId: string) {
     try {
@@ -242,8 +368,6 @@ export function useAiChat() {
     return '新对话'
   }
 
-  // ============ 消息发送 ============
-
   // ============ 文件上传 ============
 
   async function handleUploadFile(file: File) {
@@ -515,6 +639,11 @@ export function useAiChat() {
     }
   }
 
+  // 清除弹窗信息(用户做出选择后)
+  function dismissSessionLoadInfo() {
+    sessionLoadDoneInfo.value = null
+  }
+
   return {
     messages,
     isStreaming,
@@ -524,6 +653,7 @@ export function useAiChat() {
     isLoadingSession,
     pendingFiles,
     isUploading,
+    sessionLoadDoneInfo,
     sendMessage,
     cancelChat,
     clearMessages,
@@ -536,5 +666,6 @@ export function useAiChat() {
     handleUploadFile,
     removePendingFile,
     renameSession,
+    dismissSessionLoadInfo,
   }
 }

+ 6 - 4
src/service/aiChat/index.ts

@@ -3,7 +3,8 @@
 // /ai/sessions          - GET   获取当前会话信息
 // /ai/sessions/list     - GET   获取会话历史列表
 // /ai/sessions/new      - POST  创建新会话
-// /ai/sessions/:id/load - POST  加载历史会话
+// /ai/sessions/:id      - POST  切换会话(恢复 AI 上下文,轻量响应)
+// /ai/sessions/:id/load - GET   流式加载回放消息(SSE)
 // /ai/sessions/:id      - PATCH 更新会话标题
 // /ai/sessions/:id      - DELETE 删除会话
 // /ai/cancel            - POST  取消当前操作
@@ -18,6 +19,7 @@ const API = {
   chat: '/ai/chat',
   sessionList: '/ai/sessions/list',
   sessionNew: '/ai/sessions/new',
+  sessionSwitch: (id: string) => `/ai/sessions/${id}`,
   sessionLoad: (id: string) => `/ai/sessions/${id}/load`,
   sessionUpdate: (id: string) => `/ai/sessions/${id}`,
   sessionDelete: (id: string) => `/ai/sessions/${id}`,
@@ -37,9 +39,9 @@ export const createAiSession = async (): Promise<{ sessionId: string, welcomeMes
   return await http.post(API.sessionNew)
 }
 
-// POST 加载历史会话
-export const loadAiSession = async (sessionId: string): Promise<{ sessionId: string, title?: string, welcomeMessage?: string }> => {
-  return await http.post(API.sessionLoad(sessionId))
+// POST 切换会话(恢复 AI 上下文,轻量响应)
+export const switchAiSession = async (sessionId: string): Promise<{ sessionId: string, title?: string, welcomeMessage?: string }> => {
+  return await http.post(API.sessionSwitch(sessionId))
 }
 
 // PATCH 更新会话标题