|
|
@@ -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,
|
|
|
}
|
|
|
}
|