fix(chat): 持久化会话与消息

This commit is contained in:
2026-09-05 10:12:09 +08:00
parent d15ceafbe0
commit feb8cc651f
15 changed files with 726 additions and 125 deletions
@@ -11,6 +11,10 @@ vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/stores/editor', () => ({ useEditorStore: () => ({}) }))
vi.mock('@/stores/workspace', () => ({ useWorkspaceStore: () => ({}) }))
vi.mock('@/components/common/MarkdownContent.vue', () => ({ default: { template: '<div />' } }))
vi.mock('@/services/chatService', () => ({
listConversations: vi.fn().mockResolvedValue({ items: [], page: { total: 0, limit: 100, offset: 0 } }),
listConversationMessages: vi.fn(), createConversation: vi.fn(), removeConversation: vi.fn(), streamChat: vi.fn(),
}))
beforeEach(() => {
setActivePinia(createPinia())
+3 -3
View File
@@ -24,7 +24,7 @@ const availableModels = computed(() => providerStore.modelsByProvider[chatStore.
onMounted(async () => {
try {
await Promise.all([providerStore.loadProviders(), skillStore.loadSkills()])
await Promise.all([providerStore.loadProviders(), skillStore.loadSkills(), chatStore.loadConversations()])
if (disposed || providerStore.error) return
const selected = providerStore.enabledProviders.find(p => p.provider_id === chatStore.selectedProviderId)
if (!selected) {
@@ -70,9 +70,9 @@ async function openCitation(citation: Citation) {
<label class="rag-toggle"><input v-model="chatStore.useRag" type="checkbox" :disabled="chatStore.isStreaming" />{{ t('检索知识库', 'Search knowledge base') }}</label>
<span class="subtle">{{ t('开启后,将相关笔记片段发送给所选模型,并显示来源。技能调用请使用智能体。', 'When enabled, relevant note excerpts are sent to the selected model and citations are shown. Use Agent for skills.') }}</span>
</header>
<div v-if="loadError || providerStore.error" class="error-banner chat-error">{{ loadError || providerStore.error }}</div>
<div v-if="loadError || providerStore.error || chatStore.historyError" class="error-banner chat-error">{{ loadError || providerStore.error || chatStore.historyError }}</div>
<main class="message-timeline">
<div v-if="!chatStore.messages.length" class="empty-state"><div><strong>{{ t('开始一段知识对话', 'Start a knowledge conversation') }}</strong><p>{{ t('请先配置模型提供商。聊天记录仅保留在本次页面会话中。', 'Configure a model provider first. Messages are kept only for this page session.') }}</p></div></div>
<div v-if="!chatStore.messages.length" class="empty-state"><div><strong>{{ t('开始一段知识对话', 'Start a knowledge conversation') }}</strong><p>{{ t('请先配置模型提供商。聊天记录保存在本地数据库中。', 'Configure a model provider first. Messages are saved in the local database.') }}</p></div></div>
<article v-for="message in chatStore.messages" :key="message.message_id" class="message" :class="message.role">
<div class="avatar">{{ message.role === 'user' ? t('你', 'You') : 'AI' }}</div>
<div class="message-body">
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useChatStore } from '@/stores/chat'
import { t } from '@/i18n'
const chatStore = useChatStore()
onMounted(() => { void chatStore.loadConversations() })
</script>
<template>
+24 -1
View File
@@ -1,10 +1,14 @@
import { SseClient } from './sseClient'
import type { ModelEvent } from '@/contracts'
import { apiClient } from './apiClient'
import type { ChatMessage, Conversation, ModelEvent, PageMeta } from '@/contracts'
export interface ChatRequest {
provider_id: string
model: string
conversation_id?: string
user_message_id?: string
assistant_message_id?: string
conversation_title?: string
system?: string
messages: Array<{
role: 'system' | 'user' | 'assistant' | 'tool'
@@ -18,6 +22,25 @@ export interface ChatRequest {
max_tokens?: number
}
export function listConversations(offset = 0, limit = 100) {
return apiClient.get<{ items: Conversation[]; page: PageMeta }>('/api/chat/conversations', { params: { limit, offset } })
}
export function createConversation(conversation: Pick<Conversation, 'conversation_id' | 'title'>) {
return apiClient.post<Conversation>('/api/chat/conversations', {
conversation_id: conversation.conversation_id,
title: conversation.title,
})
}
export function listConversationMessages(conversationId: string, offset = 0, limit = 500) {
return apiClient.get<{ items: ChatMessage[]; page: PageMeta }>(`/api/chat/conversations/${encodeURIComponent(conversationId)}/messages`, { params: { limit, offset } })
}
export function removeConversation(conversationId: string) {
return apiClient.delete(`/api/chat/conversations/${encodeURIComponent(conversationId)}`)
}
export function streamChat(
request: ChatRequest,
handlers: {
+63 -11
View File
@@ -1,36 +1,78 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useChatStore } from './chat'
import { streamChat } from '@/services/chatService'
import {
createConversation,
listConversationMessages,
listConversations,
removeConversation,
streamChat,
} from '@/services/chatService'
import type { ChatMessage, Conversation } from '@/contracts'
import type { SseClient } from '@/services/sseClient'
vi.mock('@/services/chatService', () => ({ streamChat: vi.fn() }))
vi.mock('@/services/chatService', () => ({
createConversation: vi.fn(),
listConversationMessages: vi.fn(),
listConversations: vi.fn(),
removeConversation: vi.fn(),
streamChat: vi.fn(),
}))
const page = { total: 0, limit: 100, offset: 0 }
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(streamChat).mockReset().mockReturnValue({ cancel: vi.fn() } as unknown as SseClient)
vi.mocked(listConversations).mockReset().mockResolvedValue({ items: [], page })
vi.mocked(listConversationMessages).mockReset().mockResolvedValue({ items: [], page: { ...page, limit: 1000 } })
vi.mocked(createConversation).mockReset().mockImplementation(async value => ({
...value, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), message_count: 0,
}))
vi.mocked(removeConversation).mockReset().mockResolvedValue(undefined)
})
it('sends real user history, applies streaming changes, and restores it when switching conversations', async () => {
it('sends persistent message ids and restores messages from the backend', async () => {
const store = useChatStore()
store.selectedProviderId = 'real'
store.selectedModel = 'configured-model'
await store.sendMessage('user input')
const [request, handlers] = vi.mocked(streamChat).mock.calls[0]!
expect(request.use_rag).toBe(true)
handlers.onEvent?.({ event: 'Citation', sequence: 0, timestamp: '', data: { note_id: 'note', block_id: 'block', file_path: 'note.md', content: 'real evidence' } })
expect(store.messages[1]?.citations?.[0]?.content).toBe('real evidence')
expect(request.user_message_id).toBe(store.messages[0]?.message_id)
expect(request.assistant_message_id).toBe(store.messages[1]?.message_id)
expect(request.messages).toEqual([{ role: 'user', content: 'user input' }])
handlers.onEvent?.({ event: 'TextDelta', sequence: 0, timestamp: '', data: { text: 'real response' } })
expect(store.messages[1]?.content).toBe('real response')
handlers.onEvent?.({ event: 'Citation', sequence: 0, timestamp: '', data: { note_id: 'note', block_id: 'block', file_path: 'note.md', heading_path: ['Heading'], content: 'real evidence' } })
handlers.onEvent?.({ event: 'TextDelta', sequence: 1, timestamp: '', data: { text: 'real response' } })
handlers.onDone?.()
const persisted = store.messages.map(message => ({ ...message })) as ChatMessage[]
vi.mocked(listConversationMessages).mockResolvedValueOnce({ items: persisted, page: { total: 2, limit: 1000, offset: 0 } })
const id = store.activeConversationId!
store.createNewConversation()
await store.createNewConversation()
expect(store.messages).toEqual([])
await store.setActiveConversation(id)
expect(store.messages.map(m => m.content)).toEqual(['user input', 'real response'])
expect(store.messages.map(message => message.content)).toEqual(['user input', 'real response'])
expect(store.messages[1]?.citations?.[0]?.heading_path).toBe('Heading')
})
it('does not send without a provider and ignores late callbacks from a cancelled conversation', async () => {
it('loads the newest persisted conversation on initialization', async () => {
const conversation: Conversation = {
conversation_id: 'persisted', title: 'Saved', created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-02T00:00:00Z', message_count: 1,
}
vi.mocked(listConversations).mockResolvedValue({ items: [conversation], page: { ...page, total: 1 } })
vi.mocked(listConversationMessages).mockResolvedValue({
items: [{ message_id: 'm1', conversation_id: 'persisted', role: 'user', content: 'saved text', created_at: '2026-01-01T00:00:00Z' }],
page: { total: 1, limit: 1000, offset: 0 },
})
const store = useChatStore()
await store.loadConversations()
expect(store.activeConversationId).toBe('persisted')
expect(store.messages[0]?.content).toBe('saved text')
})
it('does not send without a provider and ignores callbacks from a cancelled conversation', async () => {
const store = useChatStore()
await store.sendMessage('no provider')
expect(streamChat).not.toHaveBeenCalled()
@@ -38,9 +80,19 @@ it('does not send without a provider and ignores late callbacks from a cancelled
store.selectedModel = 'configured-model'
await store.sendMessage('first')
const old = vi.mocked(streamChat).mock.calls[0]![1]
store.createNewConversation()
await store.createNewConversation()
await store.sendMessage('second')
old.onDone?.()
expect(store.isStreaming).toBe(true)
expect(store.messages[0]?.content).toBe('second')
})
it('keeps a conversation visible when backend deletion fails', async () => {
const store = useChatStore()
await store.createNewConversation()
const id = store.activeConversationId!
vi.mocked(removeConversation).mockRejectedValueOnce(new Error('offline'))
await store.deleteConversation(id)
expect(store.conversations.some(item => item.conversation_id === id)).toBe(true)
expect(store.historyError).toBe('offline')
})
+163 -101
View File
@@ -1,7 +1,13 @@
import { computed, reactive, ref } from 'vue'
import { defineStore } from 'pinia'
import { ref, computed, reactive } from 'vue'
import type { ChatMessage, Conversation } from '@/contracts'
import { streamChat } from '@/services/chatService'
import type { ChatMessage, Citation, Conversation } from '@/contracts'
import {
createConversation as createConversationApi,
listConversationMessages,
listConversations as listConversationsApi,
removeConversation,
streamChat,
} from '@/services/chatService'
import type { SseClient } from '@/services/sseClient'
import { t } from '@/i18n'
@@ -15,68 +21,155 @@ export const useChatStore = defineStore('chat', () => {
const selectedSkillId = ref<string | null>(null)
const selectedProviderId = ref('')
const selectedModel = ref('')
const historyError = ref('')
let initialized = false
let loading: Promise<void> | null = null
let loadVersion = 0
let sseClient: SseClient | null = null
let streamVersion = 0
// User-created conversations live in this browser session; no fabricated history.
const history = reactive<Record<string, ChatMessage[]>>({})
const pendingCreates = new Map<string, Promise<void>>()
const activeConversation = computed(() =>
conversations.value.find((c) => c.conversation_id === activeConversationId.value) || null
conversations.value.find(item => item.conversation_id === activeConversationId.value) || null
)
const sortedConversations = computed(() =>
[...conversations.value].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
)
function normalizeMessage(message: ChatMessage): ChatMessage {
return {
...message,
citations: message.citations?.map(citation => ({
...citation,
heading_path: Array.isArray(citation.heading_path)
? citation.heading_path.join(' / ')
: citation.heading_path,
} as Citation)),
}
}
async function fetchAllConversations() {
const items: Conversation[] = []
while (true) {
const result = await listConversationsApi(items.length, 100)
items.push(...result.items)
if (!result.items.length || items.length >= result.page.total) return items
}
}
async function fetchAllMessages(conversationId: string) {
const items: ChatMessage[] = []
while (true) {
const result = await listConversationMessages(conversationId, items.length, 500)
items.push(...result.items)
if (!result.items.length || items.length >= result.page.total) return items
}
}
async function loadConversations(force = false) {
if (loading) return loading
if (initialized && !force) return
const version = ++loadVersion
loading = (async () => {
historyError.value = ''
try {
const items = await fetchAllConversations()
if (version !== loadVersion) return
conversations.value = items
initialized = true
const selected = activeConversationId.value && items.some(item => item.conversation_id === activeConversationId.value)
? activeConversationId.value
: items[0]?.conversation_id || null
if (selected) await setActiveConversation(selected)
else { activeConversationId.value = null; messages.value = [] }
} catch (error) {
if (version === loadVersion) historyError.value = error instanceof Error ? error.message : t('聊天记录加载失败', 'Failed to load chat history')
} finally {
loading = null
}
})()
return loading
}
async function setActiveConversation(id: string) {
stopGeneration()
const version = ++loadVersion
activeConversationId.value = id
messages.value = history[id] ?? []
messages.value = []
historyError.value = ''
try {
const loadedMessages = await fetchAllMessages(id)
if (version === loadVersion && activeConversationId.value === id) {
messages.value = loadedMessages.map(normalizeMessage)
}
} catch (error) {
if (version === loadVersion) historyError.value = error instanceof Error ? error.message : t('消息加载失败', 'Failed to load messages')
}
}
function addLocalConversation(title: string) {
loadVersion++
const now = new Date().toISOString()
const conversation: Conversation = {
conversation_id: crypto.randomUUID(), title, created_at: now, updated_at: now, message_count: 0,
}
conversations.value.unshift(conversation)
activeConversationId.value = conversation.conversation_id
messages.value = []
return conversation
}
async function persistConversation(conversation: Conversation) {
const promise = createConversationApi(conversation).then(saved => {
const index = conversations.value.findIndex(item => item.conversation_id === saved.conversation_id)
if (index >= 0) Object.assign(conversations.value[index]!, saved)
}).catch(error => {
conversations.value = conversations.value.filter(item => item.conversation_id !== conversation.conversation_id)
if (activeConversationId.value === conversation.conversation_id) {
activeConversationId.value = null
messages.value = []
}
historyError.value = error instanceof Error ? error.message : t('会话创建失败', 'Failed to create conversation')
throw error
}).finally(() => pendingCreates.delete(conversation.conversation_id))
pendingCreates.set(conversation.conversation_id, promise)
return promise
}
async function createNewConversation() {
stopGeneration()
historyError.value = ''
const conversation = addLocalConversation(t('新对话', 'New conversation'))
try { await persistConversation(conversation) } catch { /* exposed through historyError */ }
}
async function sendMessage(text: string) {
if (!text.trim() || isStreaming.value || !selectedProviderId.value || !selectedModel.value) return
const conversationId = activeConversationId.value || crypto.randomUUID()
if (!activeConversationId.value) {
const newConv: Conversation = {
conversation_id: conversationId,
title: text.slice(0, 30),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
message_count: 0,
}
conversations.value.unshift(newConv)
activeConversationId.value = conversationId
const content = text.trim()
if (!content || isStreaming.value || !selectedProviderId.value || !selectedModel.value) return
historyError.value = ''
let conversation = activeConversation.value
if (!conversation) {
conversation = addLocalConversation(content.slice(0, 30))
try { await persistConversation(conversation) } catch { return }
} else if (pendingCreates.has(conversation.conversation_id)) {
try { await pendingCreates.get(conversation.conversation_id) } catch { return }
}
history[conversationId] = messages.value
const conversationMessages = messages.value
const conversationId = conversation.conversation_id
if (conversation.message_count === 0) conversation.title = content.slice(0, 30)
const userMsg: ChatMessage = {
message_id: crypto.randomUUID(),
conversation_id: conversationId,
role: 'user',
content: text,
message_id: crypto.randomUUID(), conversation_id: conversationId, role: 'user', content,
created_at: new Date().toISOString(),
}
messages.value.push(userMsg)
const aiMsg = reactive<ChatMessage>({
message_id: crypto.randomUUID(), conversation_id: conversationId, role: 'assistant', content: '',
created_at: new Date().toISOString(), citations: [], tool_calls: [],
})
messages.value.push(userMsg, aiMsg)
inputText.value = ''
isStreaming.value = true
const conversation = conversations.value.find(c => c.conversation_id === conversationId)
if (conversation) { conversation.updated_at = new Date().toISOString(); conversation.message_count = messages.value.length }
// 先插入占位消息,随后将 SSE 增量原位合并,避免每个 token 重建消息列表。
const aiMsg = reactive<ChatMessage>({
message_id: crypto.randomUUID(),
conversation_id: conversationId,
role: 'assistant',
content: '',
created_at: new Date().toISOString(),
citations: [],
tool_calls: [],
})
messages.value.push(aiMsg)
conversation.updated_at = new Date().toISOString()
conversation.message_count = messages.value.length
const version = ++streamVersion
const argumentBuffers = new Map<string, string>()
@@ -84,10 +177,13 @@ export const useChatStore = defineStore('chat', () => {
provider_id: selectedProviderId.value,
model: selectedModel.value,
conversation_id: conversationId,
user_message_id: userMsg.message_id,
assistant_message_id: aiMsg.message_id,
conversation_title: conversation.title,
use_rag: useRag.value,
messages: messages.value
.filter((message) => message.message_id !== aiMsg.message_id)
.map((message) => ({ role: message.role, content: message.content })),
.filter(message => message.message_id !== aiMsg.message_id)
.map(message => ({ role: message.role, content: message.content })),
}, {
onEvent(event) {
if (version !== streamVersion) return
@@ -95,25 +191,21 @@ export const useChatStore = defineStore('chat', () => {
if (event.event === 'ThinkingDelta') aiMsg.thinking = `${aiMsg.thinking ?? ''}${String(event.data.text ?? '')}`
if (event.event === 'ToolCallStart') {
aiMsg.tool_calls?.push({
tool_call_id: String(event.data.tool_call_id ?? ''),
name: String(event.data.name ?? 'unknown'),
parameters: (event.data.arguments ?? {}) as Record<string, unknown>,
status: 'running',
tool_call_id: String(event.data.tool_call_id ?? ''), name: String(event.data.name ?? 'unknown'),
parameters: (event.data.arguments ?? {}) as Record<string, unknown>, status: 'running',
})
}
if (event.event === 'ToolCallDelta') {
const call = aiMsg.tool_calls?.find((item) => item.tool_call_id === event.data.tool_call_id)
const call = aiMsg.tool_calls?.find(item => item.tool_call_id === event.data.tool_call_id)
if (call && typeof event.data.arguments_delta === 'string') {
const buffer = (argumentBuffers.get(call.tool_call_id) ?? '') + event.data.arguments_delta
argumentBuffers.set(call.tool_call_id, buffer)
try { call.parameters = JSON.parse(buffer) } catch { /* incomplete JSON fragment */ }
}
if (call && event.data.arguments && typeof event.data.arguments === 'object') {
Object.assign(call.parameters, event.data.arguments)
}
if (call && event.data.arguments && typeof event.data.arguments === 'object') Object.assign(call.parameters, event.data.arguments)
}
if (event.event === 'ToolCallEnd') {
const call = aiMsg.tool_calls?.find((item) => item.tool_call_id === event.data.tool_call_id)
const call = aiMsg.tool_calls?.find(item => item.tool_call_id === event.data.tool_call_id)
if (call) call.status = 'completed'
}
if (event.event === 'Usage') {
@@ -139,11 +231,8 @@ export const useChatStore = defineStore('chat', () => {
},
onDone() {
if (version !== streamVersion) return
const conversation = conversations.value.find((item) => item.conversation_id === conversationId)
if (conversation) {
conversation.message_count = conversationMessages.length
conversation.updated_at = new Date().toISOString()
}
conversation!.message_count = messages.value.length
conversation!.updated_at = new Date().toISOString()
isStreaming.value = false
sseClient = null
},
@@ -152,57 +241,30 @@ export const useChatStore = defineStore('chat', () => {
function stopGeneration() {
streamVersion++
if (sseClient) {
sseClient.cancel()
sseClient = null
}
if (sseClient) { sseClient.cancel(); sseClient = null }
isStreaming.value = false
}
function createNewConversation() {
stopGeneration()
const newConv: Conversation = {
conversation_id: crypto.randomUUID(),
title: t('新对话', 'New conversation'),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
message_count: 0,
}
conversations.value.unshift(newConv)
activeConversationId.value = newConv.conversation_id
history[newConv.conversation_id] = []
messages.value = history[newConv.conversation_id]
}
function deleteConversation(id: string) {
async function deleteConversation(id: string) {
if (activeConversationId.value === id) stopGeneration()
delete history[id]
const idx = conversations.value.findIndex((c) => c.conversation_id === id)
if (idx > -1) {
conversations.value.splice(idx, 1)
historyError.value = ''
try {
if (pendingCreates.has(id)) await pendingCreates.get(id)
await removeConversation(id)
conversations.value = conversations.value.filter(item => item.conversation_id !== id)
if (activeConversationId.value === id) {
activeConversationId.value = conversations.value[0]?.conversation_id || null
messages.value = conversations.value[0] ? history[conversations.value[0].conversation_id] || [] : []
const next = sortedConversations.value[0]
if (next) await setActiveConversation(next.conversation_id)
else { activeConversationId.value = null; messages.value = [] }
}
} catch (error) {
historyError.value = error instanceof Error ? error.message : t('会话删除失败', 'Failed to delete conversation')
}
}
return {
conversations,
activeConversationId,
activeConversation,
sortedConversations,
messages,
isStreaming,
inputText,
useRag,
selectedSkillId,
selectedProviderId,
selectedModel,
setActiveConversation,
sendMessage,
stopGeneration,
createNewConversation,
deleteConversation,
conversations, activeConversationId, activeConversation, sortedConversations, messages,
isStreaming, inputText, useRag, selectedSkillId, selectedProviderId, selectedModel, historyError,
loadConversations, setActiveConversation, sendMessage, stopGeneration, createNewConversation, deleteConversation,
}
})