Feat(frontend)完善前端中英文支持与表单样式,持久化聊天记录并修复会话并发问题 #24

Merged
Kronecker merged 7 commits from feat/frontend-i18n-spellcheck into main 2026-09-05 15:27:02 +08:00
5 changed files with 200 additions and 13 deletions
Showing only changes of commit cce96588e2 - Show all commits
+4
View File
@@ -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),
+40 -1
View File
@@ -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())
+2 -2
View File
@@ -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>
+128
View File
@@ -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)
})
+23 -7
View File
@@ -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,
}
})