feat: improve chat retrieval, message versions and Markdown rendering

This commit is contained in:
2026-09-06 21:39:40 +08:00
parent 174e723545
commit 637ddbb9bf
34 changed files with 1072 additions and 62 deletions
+29
View File
@@ -18,6 +18,7 @@ vi.mock('@/services/chatService', () => ({
listConversations: vi.fn(),
removeConversation: vi.fn(),
streamChat: vi.fn(),
selectMessageVersion: vi.fn().mockResolvedValue({ status: 'completed' }),
}))
const page = { total: 0, limit: 100, offset: 0 }
@@ -40,6 +41,34 @@ beforeEach(() => {
vi.mocked(removeConversation).mockReset().mockResolvedValue(undefined)
})
it('keeps reasoning and tools ordered and retries only the selected branch prefix', async () => {
const store = useChatStore()
store.selectedProviderId = 'real'
store.selectedModel = 'model'
await store.sendMessage('original')
const first = vi.mocked(streamChat).mock.calls[0]![1]
const event = (name: string, data: Record<string, unknown>) => first.onEvent?.({ event: name as 'ThinkingDelta', sequence: 0, data, timestamp: new Date().toISOString() })
event('ThinkingDelta', { text: 'before' })
event('ToolCallStart', { tool_call_id: 'tool', name: 'rag.search' })
event('ThinkingDelta', { text: 'after' })
event('TextDelta', { text: 'answer' })
expect(store.messages[1]!.activity).toEqual([{ type: 'thinking', text: 'before' }, { type: 'tool', tool_call_id: 'tool' }, { type: 'thinking', text: 'after' }])
first.onDone?.()
const originalUser = store.messages[0]!.message_id
const originalAnswer = store.messages[1]!.message_id
await store.retryMessage(originalAnswer)
const second = vi.mocked(streamChat).mock.calls[1]!
expect(second[0].retry_message_id).toBe(originalAnswer)
expect(second[0].user_message_id).toBe(originalUser)
expect(second[0].messages).toEqual([{ role: 'user', content: 'original' }])
expect(store.messages[1]!.versions).toContain(originalAnswer)
second[1].onDone?.()
await store.retryMessage(originalUser, 'edited')
expect(vi.mocked(streamChat).mock.calls[2]![0].messages).toEqual([{ role: 'user', content: 'edited' }])
expect(store.messages[0]!.versions).toContain(originalUser)
expect(store.messages[0]!.message_id).not.toBe(originalUser)
})
it('sends persistent message ids and restores messages from the backend', async () => {
const store = useChatStore()
store.selectedProviderId = 'real'
+52 -8
View File
@@ -7,6 +7,7 @@ import {
listConversations as listConversationsApi,
removeConversation,
streamChat,
selectMessageVersion,
} from '@/services/chatService'
import type { SseClient } from '@/services/sseClient'
import { t } from '@/i18n'
@@ -156,7 +157,7 @@ export const useChatStore = defineStore('chat', () => {
try { await persistConversation(conversation) } catch { /* exposed through historyError */ }
}
async function sendMessage(text: string) {
async function sendMessage(text: string, retryMessageId?: string) {
const content = text.trim()
if (!content || !canSend.value || !selectedProviderId.value || !selectedModel.value) return
const version = ++streamVersion
@@ -180,15 +181,26 @@ export const useChatStore = defineStore('chat', () => {
const conversationId = conversation.conversation_id
if (conversation.message_count === 0) conversation.title = content.slice(0, 30)
const userMsg: ChatMessage = {
const retryIndex = retryMessageId ? messages.value.findIndex(m => m.message_id === retryMessageId) : -1
const retryTarget = retryIndex >= 0 ? messages.value[retryIndex] : undefined
if (retryMessageId && !retryTarget) return
const originalMessages = retryTarget ? [...messages.value] : null
const regenerate = retryTarget?.role === 'assistant'
const userMsg: ChatMessage = regenerate ? messages.value[retryIndex - 1]! : {
message_id: crypto.randomUUID(), conversation_id: conversationId, role: 'user', content,
created_at: new Date().toISOString(),
}
const aiMsg = reactive<ChatMessage>({
message_id: crypto.randomUUID(), conversation_id: conversationId, role: 'assistant', content: '',
created_at: new Date().toISOString(), citations: [], tool_calls: [],
created_at: new Date().toISOString(), citations: [], tool_calls: [], activity: [],
})
messages.value.push(userMsg, aiMsg)
if (retryTarget) {
messages.value = messages.value.slice(0, retryIndex)
const newVersion = regenerate ? aiMsg : userMsg
newVersion.versions = [...(retryTarget.versions?.length ? retryTarget.versions : [retryTarget.message_id]), newVersion.message_id]
}
if (!regenerate) messages.value.push(userMsg)
messages.value.push(aiMsg)
inputText.value = ''
isStreaming.value = true
conversation.updated_at = new Date().toISOString()
@@ -197,6 +209,7 @@ export const useChatStore = defineStore('chat', () => {
const argumentBuffers = new Map<string, string>()
sseClient = streamChat({
provider_id: selectedProviderId.value,
...(retryMessageId ? { retry_message_id: retryMessageId } : {}),
model: selectedModel.value,
conversation_id: conversationId,
user_message_id: userMsg.message_id,
@@ -205,13 +218,22 @@ export const useChatStore = defineStore('chat', () => {
use_rag: useRag.value,
messages: messages.value
.filter(message => message.message_id !== aiMsg.message_id)
.map(message => ({ role: message.role, content: message.content })),
.map(message => ({ role: message.role, content: message.content,
...(message.role === 'assistant' && message.thinking != null ? { reasoning_content: message.thinking } : {}),
})),
}, {
onEvent(event) {
if (version !== streamVersion) return
if (event.event === 'TextDelta') aiMsg.content += String(event.data.text ?? '')
if (event.event === 'ThinkingDelta') aiMsg.thinking = `${aiMsg.thinking ?? ''}${String(event.data.text ?? '')}`
if (event.event === 'ThinkingDelta') {
const text = String(event.data.text ?? '')
aiMsg.thinking = `${aiMsg.thinking ?? ''}${text}`
const last = aiMsg.activity?.at(-1)
if (last?.type === 'thinking') last.text += text
else aiMsg.activity?.push({ type: 'thinking', text })
}
if (event.event === 'ToolCallStart') {
aiMsg.activity?.push({ type: 'tool', tool_call_id: String(event.data.tool_call_id ?? '') })
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',
@@ -228,7 +250,7 @@ export const useChatStore = defineStore('chat', () => {
}
if (event.event === 'ToolCallEnd') {
const call = aiMsg.tool_calls?.find(item => item.tool_call_id === event.data.tool_call_id)
if (call) call.status = 'completed'
if (call) call.status = event.data.status === 'failed' ? 'error' : 'completed'
}
if (event.event === 'Usage') {
const input = Number(event.data.input_tokens ?? 0)
@@ -249,6 +271,7 @@ export const useChatStore = defineStore('chat', () => {
onError(error) {
if (version !== streamVersion) return
aiMsg.content += `\n\n${t('连接失败:', 'Connection failed: ')}${error.message}`
if (originalMessages) historyError.value = t('重试连接失败,可切换版本恢复原回复。', 'Retry connection failed. Switch versions to return to the original reply.')
isStreaming.value = false
sseClient = null
},
@@ -262,6 +285,27 @@ export const useChatStore = defineStore('chat', () => {
})
}
async function retryMessage(messageId: string, editedText?: string) {
if (!canSend.value) return
const index = messages.value.findIndex(m => m.message_id === messageId)
const message = messages.value[index]
if (!message) return
const text = message.role === 'user' ? editedText : messages.value[index - 1]?.content
if (text?.trim()) await sendMessage(text, messageId)
}
async function switchVersion(messageId: string) {
const id = activeConversationId.value
if (!canSend.value || !id) return
const version = loadVersion
isPreparing.value = true
try {
await selectMessageVersion(id, messageId)
if (activeConversationId.value === id && loadVersion === version) await setActiveConversation(id)
} catch (error) { historyError.value = error instanceof Error ? error.message : 'Version switch failed' }
finally { isPreparing.value = false }
}
function stopGeneration() {
streamVersion++
isPreparing.value = false
@@ -294,6 +338,6 @@ export const useChatStore = defineStore('chat', () => {
return {
conversations, activeConversationId, activeConversation, sortedConversations, messages,
isStreaming, isPreparing, canSend, inputText, useRag, selectedSkillId, selectedProviderId, selectedModel, historyError, contextNotice,
loadConversations, setActiveConversation, sendMessage, stopGeneration, createNewConversation, deleteConversation,
loadConversations, setActiveConversation, sendMessage, stopGeneration, createNewConversation, deleteConversation, retryMessage, switchVersion,
}
})