Feat(frontend)完善前端中英文支持与表单样式,持久化聊天记录并修复会话并发问题 #24
@@ -147,6 +147,10 @@ def _append_message_in_transaction(
|
||||
"SELECT 1 FROM chat_conversations WHERE conversation_id=?", (conversation_id,)
|
||||
).fetchone()
|
||||
if conversation is None:
|
||||
# A stream may finish after deletion. Check under BEGIN IMMEDIATE so
|
||||
# deletion and assistant persistence cannot recreate an orphaned chat.
|
||||
if role == "assistant":
|
||||
return
|
||||
conn.execute(
|
||||
"INSERT INTO chat_conversations(conversation_id,title,created_at,updated_at) VALUES(?,?,?,?)",
|
||||
(conversation_id, title, now, now),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
|
||||
from app.contracts import ModelEvent, ModelEventType
|
||||
from app.contracts import ChatRequest, ModelEvent, ModelEventType
|
||||
from app.main import app
|
||||
from app.services import chat_history
|
||||
|
||||
@@ -80,3 +82,40 @@ def test_chat_conversation_crud_api() -> None:
|
||||
missing = client.get("/api/chat/conversations/crud/messages")
|
||||
assert missing.status_code == 404
|
||||
assert missing.json()["error"]["code"] == "CONVERSATION_NOT_FOUND"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("close_early", [True, False])
|
||||
@pytest.mark.parametrize("deleted", [True, False])
|
||||
def test_stream_finalization_respects_conversation_deletion(monkeypatch, close_early, deleted) -> None:
|
||||
from app import routes
|
||||
|
||||
class Adapter:
|
||||
async def stream(self, _request):
|
||||
now = datetime.now(timezone.utc)
|
||||
yield ModelEvent(event=ModelEventType.text_delta, data={"text": "partial answer"}, timestamp=now)
|
||||
yield ModelEvent(event=ModelEventType.done, timestamp=now)
|
||||
|
||||
monkeypatch.setattr(routes, "provider_or_404", lambda _: SimpleNamespace(adapter=Adapter()))
|
||||
|
||||
async def scenario():
|
||||
response = await routes.chat(ChatRequest(
|
||||
provider_id="configured", model="model", conversation_id="stream",
|
||||
use_rag=False, messages=[{"role": "user", "content": "question"}],
|
||||
))
|
||||
await anext(response.body_iterator)
|
||||
if deleted:
|
||||
assert chat_history.delete("stream")
|
||||
if close_early:
|
||||
await response.body_iterator.aclose()
|
||||
else:
|
||||
async for _ in response.body_iterator:
|
||||
pass
|
||||
if deleted:
|
||||
assert chat_history.get("stream") is None
|
||||
assert chat_history.list_conversations(50, 0)[1] == 0
|
||||
else:
|
||||
messages, total = chat_history.list_messages("stream", 50, 0)
|
||||
assert total == 2
|
||||
assert [message.content for message in messages] == ["question", "partial answer"]
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
@@ -94,8 +94,8 @@ async function openCitation(citation: Citation) {
|
||||
<textarea v-model="chatStore.inputText" class="textarea" :placeholder="t('输入问题,Ctrl + Enter 发送', 'Enter a question; press Ctrl + Enter to send')"
|
||||
@keydown.ctrl.enter.prevent="send" />
|
||||
<div class="composer-actions"><span class="subtle">{{ t('回答可能包含错误,请核对 Citation。', 'Answers may contain errors. Verify the citations.') }}</span>
|
||||
<button v-if="chatStore.isStreaming" class="button-danger" @click="chatStore.stopGeneration">{{ t('停止', 'Stop') }}</button>
|
||||
<button v-else class="button-primary" :disabled="!chatStore.inputText.trim() || !chatStore.selectedProviderId || !chatStore.selectedModel.trim()" @click="send">{{ t('发送', 'Send') }}</button>
|
||||
<button v-if="chatStore.isStreaming || chatStore.isPreparing" class="button-danger" @click="chatStore.stopGeneration">{{ t('停止', 'Stop') }}</button>
|
||||
<button v-else class="button-primary" :disabled="!chatStore.canSend || !chatStore.inputText.trim() || !chatStore.selectedProviderId || !chatStore.selectedModel.trim()" @click="send">{{ t('发送', 'Send') }}</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
@@ -21,6 +21,12 @@ vi.mock('@/services/chatService', () => ({
|
||||
|
||||
const page = { total: 0, limit: 100, offset: 0 }
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>(done => { resolve = done })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.mocked(streamChat).mockReset().mockReturnValue({ cancel: vi.fn() } as unknown as SseClient)
|
||||
@@ -96,3 +102,125 @@ it('keeps a conversation visible when backend deletion fails', async () => {
|
||||
expect(store.conversations.some(item => item.conversation_id === id)).toBe(true)
|
||||
expect(store.historyError).toBe('offline')
|
||||
})
|
||||
|
||||
it('blocks sends until history is loaded, then includes that history', async () => {
|
||||
const store = useChatStore()
|
||||
store.selectedProviderId = 'real'
|
||||
store.selectedModel = 'model'
|
||||
await store.createNewConversation()
|
||||
const id = store.activeConversationId!
|
||||
const history = deferred<Awaited<ReturnType<typeof listConversationMessages>>>()
|
||||
vi.mocked(listConversationMessages).mockReturnValueOnce(history.promise)
|
||||
const loading = store.setActiveConversation(id)
|
||||
store.inputText = 'followup'
|
||||
expect(store.canSend).toBe(false)
|
||||
await store.sendMessage(store.inputText)
|
||||
expect(streamChat).not.toHaveBeenCalled()
|
||||
expect(store.inputText).toBe('followup')
|
||||
history.resolve({ items: [{ message_id: 'old', conversation_id: id, role: 'user', content: 'previous context', created_at: '' }], page: { ...page, total: 1 } })
|
||||
await loading
|
||||
expect(store.canSend).toBe(true)
|
||||
await store.sendMessage(store.inputText)
|
||||
expect(vi.mocked(streamChat).mock.calls[0]![0].messages).toEqual([
|
||||
{ role: 'user', content: 'previous context' }, { role: 'user', content: 'followup' },
|
||||
])
|
||||
expect(store.messages.map(m => m.content)).toEqual(['previous context', 'followup', ''])
|
||||
})
|
||||
|
||||
it('keeps sending blocked after history failure until a successful retry', async () => {
|
||||
const store = useChatStore()
|
||||
store.selectedProviderId = 'real'
|
||||
store.selectedModel = 'model'
|
||||
await store.createNewConversation()
|
||||
const id = store.activeConversationId!
|
||||
vi.mocked(listConversationMessages).mockRejectedValueOnce(new Error('offline'))
|
||||
await store.setActiveConversation(id)
|
||||
await store.sendMessage('followup')
|
||||
expect(streamChat).not.toHaveBeenCalled()
|
||||
expect(store.historyError).toBe('offline')
|
||||
expect(store.canSend).toBe(false)
|
||||
await store.setActiveConversation(id)
|
||||
expect(store.canSend).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['switch', 'stop', 'delete'] as const)('cancels a pending send on %s without touching another send', async action => {
|
||||
const store = useChatStore()
|
||||
store.selectedProviderId = 'real'
|
||||
store.selectedModel = 'model'
|
||||
await store.createNewConversation()
|
||||
const b = store.activeConversationId!
|
||||
const creation = deferred<Conversation>()
|
||||
vi.mocked(createConversation).mockReturnValueOnce(creation.promise)
|
||||
const creating = store.createNewConversation()
|
||||
const a = store.activeConversationId!
|
||||
const saved = { ...store.activeConversation! }
|
||||
const sending = store.sendMessage('belongs to a')
|
||||
expect(store.isPreparing).toBe(true)
|
||||
const deleting = action === 'delete' ? store.deleteConversation(a) : undefined
|
||||
if (action === 'stop') store.stopGeneration()
|
||||
await store.setActiveConversation(b)
|
||||
await store.sendMessage('belongs to b')
|
||||
creation.resolve(saved)
|
||||
await Promise.all([creating, sending, deleting])
|
||||
expect(store.activeConversationId).toBe(b)
|
||||
expect(store.messages.every(m => m.conversation_id === b)).toBe(true)
|
||||
expect(store.messages[0]?.content).toBe('belongs to b')
|
||||
expect(streamChat).toHaveBeenCalledTimes(1)
|
||||
expect(store.isStreaming).toBe(true)
|
||||
})
|
||||
|
||||
it('locks the initial send while creating its conversation and allows retry after failure', async () => {
|
||||
const store = useChatStore()
|
||||
store.selectedProviderId = 'real'
|
||||
store.selectedModel = 'model'
|
||||
const creation = deferred<Conversation>()
|
||||
vi.mocked(createConversation).mockReturnValueOnce(creation.promise)
|
||||
const sending = store.sendMessage('first')
|
||||
await store.sendMessage('duplicate')
|
||||
expect(createConversation).toHaveBeenCalledTimes(1)
|
||||
expect(streamChat).not.toHaveBeenCalled()
|
||||
creation.resolve({ ...store.activeConversation! })
|
||||
await sending
|
||||
expect(streamChat).toHaveBeenCalledTimes(1)
|
||||
store.stopGeneration()
|
||||
store.activeConversationId = null
|
||||
vi.mocked(createConversation).mockRejectedValueOnce(new Error('offline'))
|
||||
await store.sendMessage('retry')
|
||||
expect(store.isPreparing).toBe(false)
|
||||
expect(store.canSend).toBe(true)
|
||||
await store.sendMessage('retry')
|
||||
expect(streamChat).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('stops the first send before creation completes without switching conversations', async () => {
|
||||
const store = useChatStore()
|
||||
store.selectedProviderId = 'real'
|
||||
store.selectedModel = 'model'
|
||||
const creation = deferred<Conversation>()
|
||||
vi.mocked(createConversation).mockReturnValueOnce(creation.promise)
|
||||
const sending = store.sendMessage('cancelled')
|
||||
const saved = { ...store.activeConversation! }
|
||||
store.stopGeneration()
|
||||
creation.resolve(saved)
|
||||
await sending
|
||||
expect(streamChat).not.toHaveBeenCalled()
|
||||
expect(store.messages).toEqual([])
|
||||
expect(store.canSend).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores old history after switching to a new conversation and sending', async () => {
|
||||
const store = useChatStore()
|
||||
store.selectedProviderId = 'real'
|
||||
store.selectedModel = 'model'
|
||||
await store.createNewConversation()
|
||||
const id = store.activeConversationId!
|
||||
const history = deferred<Awaited<ReturnType<typeof listConversationMessages>>>()
|
||||
vi.mocked(listConversationMessages).mockReturnValueOnce(history.promise)
|
||||
const loading = store.setActiveConversation(id)
|
||||
await store.createNewConversation()
|
||||
await store.sendMessage('new question')
|
||||
history.resolve({ items: [], page })
|
||||
await loading
|
||||
expect(store.messages.map(m => m.content)).toEqual(['new question', ''])
|
||||
expect(store.isStreaming).toBe(true)
|
||||
})
|
||||
|
||||
@@ -16,6 +16,9 @@ export const useChatStore = defineStore('chat', () => {
|
||||
const activeConversationId = ref<string | null>(null)
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const isStreaming = ref(false)
|
||||
const isPreparing = ref(false)
|
||||
const messagesReady = ref(true)
|
||||
const canSend = computed(() => messagesReady.value && !isPreparing.value && !isStreaming.value)
|
||||
const inputText = ref('')
|
||||
const useRag = ref(true)
|
||||
const selectedSkillId = ref<string | null>(null)
|
||||
@@ -70,6 +73,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
if (loading) return loading
|
||||
if (initialized && !force) return
|
||||
const version = ++loadVersion
|
||||
messagesReady.value = false
|
||||
loading = (async () => {
|
||||
historyError.value = ''
|
||||
try {
|
||||
@@ -81,7 +85,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
? activeConversationId.value
|
||||
: items[0]?.conversation_id || null
|
||||
if (selected) await setActiveConversation(selected)
|
||||
else { activeConversationId.value = null; messages.value = [] }
|
||||
else { activeConversationId.value = null; messages.value = []; messagesReady.value = true }
|
||||
} catch (error) {
|
||||
if (version === loadVersion) historyError.value = error instanceof Error ? error.message : t('聊天记录加载失败', 'Failed to load chat history')
|
||||
} finally {
|
||||
@@ -95,12 +99,14 @@ export const useChatStore = defineStore('chat', () => {
|
||||
stopGeneration()
|
||||
const version = ++loadVersion
|
||||
activeConversationId.value = id
|
||||
messagesReady.value = false
|
||||
messages.value = []
|
||||
historyError.value = ''
|
||||
try {
|
||||
const loadedMessages = await fetchAllMessages(id)
|
||||
if (version === loadVersion && activeConversationId.value === id) {
|
||||
messages.value = loadedMessages.map(normalizeMessage)
|
||||
messagesReady.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
if (version === loadVersion) historyError.value = error instanceof Error ? error.message : t('消息加载失败', 'Failed to load messages')
|
||||
@@ -116,6 +122,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
conversations.value.unshift(conversation)
|
||||
activeConversationId.value = conversation.conversation_id
|
||||
messages.value = []
|
||||
messagesReady.value = true
|
||||
return conversation
|
||||
}
|
||||
|
||||
@@ -145,15 +152,24 @@ export const useChatStore = defineStore('chat', () => {
|
||||
|
||||
async function sendMessage(text: string) {
|
||||
const content = text.trim()
|
||||
if (!content || isStreaming.value || !selectedProviderId.value || !selectedModel.value) return
|
||||
if (!content || !canSend.value || !selectedProviderId.value || !selectedModel.value) return
|
||||
const version = ++streamVersion
|
||||
isPreparing.value = true
|
||||
historyError.value = ''
|
||||
let conversation = activeConversation.value
|
||||
try {
|
||||
if (!conversation) {
|
||||
conversation = addLocalConversation(content.slice(0, 30))
|
||||
try { await persistConversation(conversation) } catch { return }
|
||||
await persistConversation(conversation)
|
||||
} else if (pendingCreates.has(conversation.conversation_id)) {
|
||||
try { await pendingCreates.get(conversation.conversation_id) } catch { return }
|
||||
await pendingCreates.get(conversation.conversation_id)
|
||||
}
|
||||
} catch { return }
|
||||
finally {
|
||||
if (version === streamVersion) isPreparing.value = false
|
||||
}
|
||||
// Switching, stopping or deleting cancels sends still waiting for creation.
|
||||
if (version !== streamVersion || activeConversationId.value !== conversation.conversation_id) return
|
||||
|
||||
const conversationId = conversation.conversation_id
|
||||
if (conversation.message_count === 0) conversation.title = content.slice(0, 30)
|
||||
@@ -171,7 +187,6 @@ export const useChatStore = defineStore('chat', () => {
|
||||
conversation.updated_at = new Date().toISOString()
|
||||
conversation.message_count = messages.value.length
|
||||
|
||||
const version = ++streamVersion
|
||||
const argumentBuffers = new Map<string, string>()
|
||||
sseClient = streamChat({
|
||||
provider_id: selectedProviderId.value,
|
||||
@@ -241,6 +256,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
|
||||
function stopGeneration() {
|
||||
streamVersion++
|
||||
isPreparing.value = false
|
||||
if (sseClient) { sseClient.cancel(); sseClient = null }
|
||||
isStreaming.value = false
|
||||
}
|
||||
@@ -255,7 +271,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
if (activeConversationId.value === id) {
|
||||
const next = sortedConversations.value[0]
|
||||
if (next) await setActiveConversation(next.conversation_id)
|
||||
else { activeConversationId.value = null; messages.value = [] }
|
||||
else { loadVersion++; activeConversationId.value = null; messages.value = []; messagesReady.value = true }
|
||||
}
|
||||
} catch (error) {
|
||||
historyError.value = error instanceof Error ? error.message : t('会话删除失败', 'Failed to delete conversation')
|
||||
@@ -264,7 +280,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
|
||||
return {
|
||||
conversations, activeConversationId, activeConversation, sortedConversations, messages,
|
||||
isStreaming, inputText, useRag, selectedSkillId, selectedProviderId, selectedModel, historyError,
|
||||
isStreaming, isPreparing, canSend, inputText, useRag, selectedSkillId, selectedProviderId, selectedModel, historyError,
|
||||
loadConversations, setActiveConversation, sendMessage, stopGeneration, createNewConversation, deleteConversation,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user