fix(chat): serialize sends and reset on cleanup (#827)

This commit is contained in:
s3d-i
2025-12-25 02:16:09 +08:00
committed by GitHub
parent d68289a532
commit 26fadcb2fb
7 changed files with 216 additions and 55 deletions
@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { ChatHistoryMessage } from '@proj-airi/stage-ui/components'
import type { ChatProvider } from '@xsai-ext/shared-providers'
import { ChatHistory } from '@proj-airi/stage-ui/components'
@@ -9,7 +10,7 @@ import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
import { BasicTextarea } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { widgetsTools } from '../stores/tools/builtin/widgets'
@@ -37,10 +38,16 @@ async function handleSend() {
return
}
const textToSend = messageInput.value
const attachmentsToSend = attachments.value.map(att => ({ ...att }))
// optimistic clear
messageInput.value = ''
attachments.value = []
try {
const providerConfig = providersStore.getProviderConfig(activeProvider.value)
const attachmentsToSend = attachments.value.map(({ data, mimeType, type }) => ({ data, mimeType, type }))
await send(messageInput.value, {
await send(textToSend, {
model: activeModel.value,
chatProvider: await providersStore.getProviderInstance<ChatProvider>(activeProvider.value),
providerConfig,
@@ -48,10 +55,15 @@ async function handleSend() {
tools: widgetsTools,
})
// clear after sending
messageInput.value = ''
attachmentsToSend.forEach(att => URL.revokeObjectURL(att.url))
}
catch (error) {
// restore on failure
messageInput.value = textToSend
attachments.value = attachmentsToSend.map(att => ({
...att,
url: URL.createObjectURL(new Blob([Uint8Array.from(atob(att.data), c => c.charCodeAt(0))], { type: att.mimeType })),
}))
messages.value.pop()
messages.value.push({
role: 'error',
@@ -142,13 +154,15 @@ onAfterMessageComposed(async () => {
attachments.value.forEach(att => URL.revokeObjectURL(att.url))
attachments.value = []
})
const historyMessages = computed(() => messages.value as unknown as ChatHistoryMessage[])
</script>
<template>
<div h-full w-full flex="~ col gap-1">
<div w-full flex-1 overflow-hidden>
<ChatHistory
:messages="messages"
:messages="historyMessages"
:sending="sending"
:streaming-message="streamingMessage"
/>
@@ -1,9 +1,11 @@
<script setup lang="ts">
import type { ChatHistoryMessage } from '@proj-airi/stage-ui/components'
import { ChatHistory } from '@proj-airi/stage-ui/components'
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
import { useDeferredMount } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import ChatActionButtons from '../Widgets/ChatActionButtons.vue'
import ChatArea from '../Widgets/ChatArea.vue'
@@ -13,6 +15,7 @@ const { isReady } = useDeferredMount()
const { messages, sending, streamingMessage } = storeToRefs(useChatStore())
const isLoading = ref(true)
const historyMessages = computed(() => messages.value as unknown as ChatHistoryMessage[])
</script>
<template>
@@ -29,7 +32,7 @@ const isLoading = ref(true)
<div w="full" max-h="<md:[60%]" py="<sm:2" flex="~ col" rounded="lg" relative h-full flex-1 overflow-hidden px="2 <md:0" py-4>
<ChatHistory
v-if="isReady"
:messages="messages"
:messages="historyMessages"
:sending="sending"
:streaming-message="streamingMessage"
h-full
@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { ChatHistoryMessage } from '@proj-airi/stage-ui/components'
import type { ChatProvider } from '@xsai-ext/shared-providers'
import { ChatHistory, HearingConfigDialog } from '@proj-airi/stage-ui/components'
@@ -11,7 +12,7 @@ import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/
import { BasicTextarea, useTheme } from '@proj-airi/ui'
import { useResizeObserver, useScreenSafeArea } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
@@ -24,7 +25,9 @@ import { BackgroundDialogPicker } from '../Backgrounds'
const { isDark, toggleDark } = useTheme()
const hearingDialogOpen = ref(false)
const { messages, sending, streamingMessage } = storeToRefs(useChatStore())
const chatStore = useChatStore()
const { messages, sending, streamingMessage } = storeToRefs(chatStore)
const historyMessages = computed(() => messages.value as unknown as ChatHistoryMessage[])
const viewControlsActiveMode = ref<'x' | 'y' | 'z' | 'scale'>('scale')
const viewControlsInputsRef = useTemplateRef<InstanceType<typeof ViewControlInputs>>('viewControlsInputs')
@@ -41,7 +44,7 @@ useResizeObserver(document.documentElement, () => screenSafeArea.update())
const { themeColorsHueDynamic, stageViewControlsEnabled } = storeToRefs(useSettings())
const settingsAudioDevice = useSettingsAudioDevice()
const { enabled, selectedAudioInput, stream, audioInputs } = storeToRefs(settingsAudioDevice)
const { send, onAfterMessageComposed, discoverToolsCompatibility, cleanupMessages } = useChatStore()
const { send, onAfterMessageComposed, discoverToolsCompatibility, cleanupMessages } = chatStore
const { t } = useI18n()
const { audioContext } = useAudioContext()
const { startAnalyzer, stopAnalyzer, volumeLevel } = useAudioAnalyzer()
@@ -62,16 +65,20 @@ async function handleSend() {
return
}
const textToSend = messageInput.value
messageInput.value = ''
try {
const providerConfig = providersStore.getProviderConfig(activeProvider.value)
await send(messageInput.value, {
await send(textToSend, {
chatProvider: await providersStore.getProviderInstance(activeProvider.value) as ChatProvider,
model: activeModel.value,
providerConfig,
})
}
catch (error) {
messageInput.value = textToSend
messages.value.pop()
messages.value.push({
role: 'error',
@@ -113,7 +120,6 @@ watch(hearingDialogOpen, (value) => {
})
onAfterMessageComposed(async () => {
messageInput.value = ''
})
watch([activeProvider, activeModel], async () => {
@@ -139,7 +145,7 @@ onMounted(() => {
<ChatHistory
v-if="!stageViewControlsEnabled"
variant="mobile"
:messages="messages"
:messages="historyMessages"
:sending="sending"
:streaming-message="streamingMessage"
max-w="[calc(100%-3.5rem)]"
@@ -25,7 +25,8 @@ const { themeColorsHueDynamic } = storeToRefs(useSettings())
const { askPermission } = useSettingsAudioDevice()
const { enabled, selectedAudioInput, stream, audioInputs } = storeToRefs(useSettingsAudioDevice())
const { send, onAfterMessageComposed, discoverToolsCompatibility } = useChatStore()
const chatStore = useChatStore()
const { send, onAfterMessageComposed, discoverToolsCompatibility } = chatStore
const { messages } = storeToRefs(useChatStore())
const { audioContext } = useAudioContext()
const { t } = useI18n()
@@ -35,16 +36,20 @@ async function handleSend() {
return
}
const textToSend = messageInput.value
messageInput.value = ''
try {
const providerConfig = providersStore.getProviderConfig(activeProvider.value)
await send(messageInput.value, {
await send(textToSend, {
chatProvider: await providersStore.getProviderInstance(activeProvider.value) as ChatProvider,
model: activeModel.value,
providerConfig,
})
}
catch (error) {
messageInput.value = textToSend
messages.value.pop()
messages.value.push({
role: 'error',
@@ -66,7 +71,6 @@ watch([activeProvider, activeModel], async () => {
})
onAfterMessageComposed(async () => {
messageInput.value = ''
})
const { startAnalyzer, stopAnalyzer, volumeLevel } = useAudioAnalyzer()
@@ -11,7 +11,7 @@ import ChatUserItem from './ChatUserItem.vue'
const props = withDefaults(defineProps<{
messages: ChatHistoryMessage[]
streamingMessage?: ChatAssistantMessage
streamingMessage?: ChatAssistantMessage & { context?: { ts?: number } }
sending?: boolean
assistantLabel?: string
userLabel?: string
@@ -46,18 +46,33 @@ watch([() => props.messages, () => props.streamingMessage], scrollToBottom, { de
watch(() => props.sending, scrollToBottom, { flush: 'post' })
onMounted(scrollToBottom)
const streaming = computed<ChatAssistantMessage>(() => props.streamingMessage ?? { role: 'assistant', content: '', slices: [], tool_results: [] })
const streaming = computed<ChatAssistantMessage & { context?: { ts?: number } }>(() => props.streamingMessage ?? { role: 'assistant', content: '', slices: [], tool_results: [] })
const showStreamingPlaceholder = computed(() => (streaming.value.slices?.length ?? 0) === 0 && !streaming.value.content)
const streamingTs = computed(() => streaming.value.context?.ts)
const renderMessages = computed<ChatHistoryMessage[]>(() => {
if (!props.sending)
return props.messages
const streamTs = streamingTs.value
if (!streamTs)
return props.messages
const hasStreamAlready = streamTs && props.messages.some(msg => msg.context?.ts === streamTs)
if (hasStreamAlready)
return props.messages
return [...props.messages, streaming.value]
})
</script>
<template>
<div ref="chatHistoryRef" v-auto-animate flex="~ col" relative h-full w-full overflow-y-auto rounded-xl px="<sm:2" py="<sm:2" :class="variant === 'mobile' ? 'gap-1' : 'gap-2'">
<template v-for="(message, index) in messages" :key="index">
<template v-for="(message, index) in renderMessages" :key="message.context?.ts ?? index">
<div v-if="message.role === 'error'">
<ChatErrorItem
:message="message"
:label="labels.error"
:show-placeholder="sending && index === messages.length - 1"
:show-placeholder="sending && index === renderMessages.length - 1"
:variant="variant"
/>
</div>
@@ -66,6 +81,7 @@ const showStreamingPlaceholder = computed(() => (streaming.value.slices?.length
<ChatAssistantItem
:message="message"
:label="labels.assistant"
:show-placeholder="message.context?.ts === streamingTs ? showStreamingPlaceholder : false"
:variant="variant"
/>
</div>
@@ -78,14 +94,5 @@ const showStreamingPlaceholder = computed(() => (streaming.value.slices?.length
/>
</div>
</template>
<div v-if="sending">
<ChatAssistantItem
:message="streaming"
:label="labels.assistant"
:show-placeholder="showStreamingPlaceholder"
:variant="variant"
/>
</div>
</div>
</template>
@@ -7,4 +7,8 @@ export interface ChatErrorMessage {
export type ChatHistoryMessage = (ChatMessage | ChatErrorMessage) & {
slices?: ChatSlices[]
context?: {
ts?: number
[key: string]: unknown
}
}
+148 -25
View File
@@ -27,8 +27,7 @@ interface MessageContext {
meta?: Record<string, unknown>
}
type ChatEntry = (ChatMessage | ErrorMessage) & { context?: MessageContext }
export type { ChatEntry }
export type ChatEntry = (ChatMessage | ErrorMessage) & { context?: MessageContext }
export interface ContextPayload {
content?: unknown
@@ -52,6 +51,8 @@ const ACTIVE_SESSION_STORAGE_KEY = 'chat/active-session'
export const CONTEXT_CHANNEL_NAME = 'airi-context-update'
export const CHAT_STREAM_CHANNEL_NAME = 'airi-chat-stream'
type StreamingAssistantMessage = ChatAssistantMessage & { context?: MessageContext }
export const useChatStore = defineStore('chat', () => {
const { stream, discoverToolsCompatibility } = useLLM()
const { systemPrompt } = storeToRefs(useAiriCardStore())
@@ -60,6 +61,62 @@ export const useChatStore = defineStore('chat', () => {
const sessionMessages = useLocalStorage<Record<string, ChatEntry[]>>(CHAT_STORAGE_KEY, {})
const sending = ref(false)
const streamingMessage = ref<StreamingAssistantMessage>({ role: 'assistant', content: '', slices: [], tool_results: [] })
const sessionGenerations = ref<Record<string, number>>({})
interface SendOptions {
model: string
chatProvider: ChatProvider
providerConfig?: Record<string, unknown>
attachments?: { type: 'image', data: string, mimeType: string }[]
tools?: StreamOptions['tools']
}
interface QueuedSend {
sendingMessage: string
options: SendOptions
generation: number
sessionId: string
cancelled?: boolean
deferred: {
resolve: () => void
reject: (error: unknown) => void
}
}
const pendingQueuedSends = ref<QueuedSend[]>([])
const sendQueue = createQueue<QueuedSend>({
handlers: [
async ({ data }) => {
const { sendingMessage, options, generation, deferred, sessionId, cancelled } = data
if (cancelled)
return
if (getSessionGeneration(sessionId) !== generation) {
deferred.reject(new Error('Chat session was reset before send could start'))
return
}
try {
await performSend(sendingMessage, options, generation, sessionId)
deferred.resolve()
}
catch (error) {
deferred.reject(error)
}
},
],
})
sendQueue.on('enqueue', (queuedSend) => {
pendingQueuedSends.value = [...pendingQueuedSends.value, queuedSend]
})
sendQueue.on('dequeue', (queuedSend) => {
pendingQueuedSends.value = pendingQueuedSends.value.filter(item => item !== queuedSend)
})
// ----- Hooks (UI callbacks) -----
const onBeforeMessageComposedHooks = ref<Array<(message: string) => Promise<void>>>([])
@@ -177,6 +234,22 @@ export const useChatStore = defineStore('chat', () => {
const codeBlockSystemPrompt = '- For any programming code block, always specify the programming language that supported on @shikijs/rehype on the rendered markdown, eg. ```python ... ```\n'
const mathSyntaxSystemPrompt = '- For any math equation, use LaTeX format, eg: $ x^3 $, always escape dollar sign outside math equation\n'
function ensureSessionGeneration(sessionId: string) {
if (sessionGenerations.value[sessionId] === undefined)
sessionGenerations.value = { ...sessionGenerations.value, [sessionId]: 0 }
}
function getSessionGeneration(sessionId: string) {
ensureSessionGeneration(sessionId)
return sessionGenerations.value[sessionId] ?? 0
}
function bumpSessionGeneration(sessionId: string) {
const nextGeneration = getSessionGeneration(sessionId) + 1
sessionGenerations.value = { ...sessionGenerations.value, [sessionId]: nextGeneration }
return nextGeneration
}
function generateInitialMessage() {
// TODO: compose, replace {{ user }} tag, etc
return {
@@ -186,6 +259,7 @@ export const useChatStore = defineStore('chat', () => {
}
function ensureSession(sessionId: string) {
ensureSessionGeneration(sessionId)
if (!sessionMessages.value[sessionId] || sessionMessages.value[sessionId].length === 0) {
sessionMessages.value[sessionId] = [{
...generateInitialMessage(),
@@ -200,6 +274,11 @@ export const useChatStore = defineStore('chat', () => {
ensureSession(activeSessionId.value)
function getSessionMessagesById(sessionId: string) {
ensureSession(sessionId)
return sessionMessages.value[sessionId]!
}
const messages = computed<ChatEntry[]>({
get: () => {
ensureSession(activeSessionId.value)
@@ -216,6 +295,7 @@ export const useChatStore = defineStore('chat', () => {
}
function cleanupMessages(sessionId = activeSessionId.value) {
bumpSessionGeneration(sessionId)
sessionMessages.value[sessionId] = [{
...generateInitialMessage(),
context: {
@@ -224,6 +304,17 @@ export const useChatStore = defineStore('chat', () => {
ts: Date.now(),
},
}]
// Reject pending sends for this session so callers don't hang after cleanup
for (const queued of pendingQueuedSends.value) {
if (queued.sessionId !== sessionId)
continue
queued.cancelled = true
queued.deferred.reject(new Error('Chat session was reset before send could start'))
}
pendingQueuedSends.value = pendingQueuedSends.value.filter(item => item.sessionId !== sessionId)
sending.value = false
streamingMessage.value = { role: 'assistant', content: '', slices: [], tool_results: [] }
}
function getAllSessions() {
@@ -232,6 +323,7 @@ export const useChatStore = defineStore('chat', () => {
function replaceSessions(sessions: Record<string, ChatEntry[]>) {
sessionMessages.value = sessions
sessionGenerations.value = Object.fromEntries(Object.keys(sessions).map(sessionId => [sessionId, 0]))
const [firstSessionId] = Object.keys(sessions)
if (!sessionMessages.value[activeSessionId.value] && firstSessionId)
activeSessionId.value = firstSessionId
@@ -241,6 +333,7 @@ export const useChatStore = defineStore('chat', () => {
function resetAllSessions() {
sessionMessages.value = {}
sessionGenerations.value = {}
activeSessionId.value = 'default'
ensureSession(activeSessionId.value)
}
@@ -319,21 +412,28 @@ export const useChatStore = defineStore('chat', () => {
}
// ----- Send flow (user -> LLM -> assistant) -----
const streamingMessage = ref<ChatAssistantMessage>({ role: 'assistant', content: '', slices: [], tool_results: [] })
async function send(
async function performSend(
sendingMessage: string,
options: {
model: string
chatProvider: ChatProvider
providerConfig?: Record<string, unknown>
attachments?: { type: 'image', data: string, mimeType: string }[]
tools?: StreamOptions['tools']
},
options: SendOptions,
generation: number,
sessionId: string,
) {
if (!sendingMessage && !options.attachments?.length)
return
ensureSession(sessionId)
const isStaleGeneration = () => getSessionGeneration(sessionId) !== generation
const shouldAbort = () => isStaleGeneration()
if (shouldAbort())
return
sending.value = true
const assistantContext: MessageContext = {
sessionId,
source: 'llm',
ts: Date.now(),
}
streamingMessage.value = { role: 'assistant', content: '', slices: [], tool_results: [], context: assistantContext }
try {
await emitBeforeMessageComposedHooks(sendingMessage)
@@ -355,8 +455,12 @@ export const useChatStore = defineStore('chat', () => {
const finalContent = contentParts.length > 1 ? contentParts : sendingMessage
const userContext: MessageContext = { sessionId: activeSessionId.value, source: 'text', ts: Date.now() }
messages.value.push({ role: 'user', content: finalContent, context: userContext })
if (shouldAbort())
return
const sessionMessagesForSend = getSessionMessagesById(sessionId)
const userContext: MessageContext = { sessionId, source: 'text', ts: Date.now() }
sessionMessagesForSend.push({ role: 'user', content: finalContent, context: userContext })
publishContextMessage({
sessionId: userContext.sessionId,
@@ -368,6 +472,8 @@ export const useChatStore = defineStore('chat', () => {
const parser = useLlmmarkerParser({
onLiteral: async (literal) => {
if (shouldAbort())
return
await emitTokenLiteralHooks(literal)
streamingMessage.value.content += literal
@@ -385,6 +491,8 @@ export const useChatStore = defineStore('chat', () => {
})
},
onSpecial: async (special) => {
if (shouldAbort())
return
await emitTokenSpecialHooks(special)
},
minLiteralEmitLength: 24, // Avoid emitting literals too fast. This is a magic number and can be changed later.
@@ -393,6 +501,8 @@ export const useChatStore = defineStore('chat', () => {
const toolCallQueue = createQueue<ChatSlices>({
handlers: [
async (ctx) => {
if (shouldAbort())
return
if (ctx.data.type === 'tool-call') {
streamingMessage.value.slices.push(ctx.data)
return
@@ -405,9 +515,7 @@ export const useChatStore = defineStore('chat', () => {
],
})
streamingMessage.value = { role: 'assistant', content: '', slices: [], tool_results: [] }
const newMessages = messages.value.map((msg) => {
const newMessages = sessionMessagesForSend.map((msg) => {
const { context: _context, ...withoutContext } = msg
const rawMessage = toRaw(withoutContext)
if (rawMessage.role === 'assistant') {
@@ -427,6 +535,9 @@ export const useChatStore = defineStore('chat', () => {
let fullText = ''
const headers = (options.providerConfig?.headers || {}) as Record<string, string>
if (shouldAbort())
return
await stream(options.model, options.chatProvider, newMessages as Message[], {
headers,
tools: options.tools,
@@ -461,19 +572,13 @@ export const useChatStore = defineStore('chat', () => {
await parser.end()
// Add the completed message to the history only if it has content
if (streamingMessage.value.slices.length > 0) {
const assistantContext: MessageContext = {
sessionId: activeSessionId.value,
source: 'llm',
ts: Date.now(),
}
if (!isStaleGeneration() && streamingMessage.value.slices.length > 0) {
const assistantMessage: ChatEntry = {
...(toRaw(streamingMessage.value) as ChatAssistantMessage),
context: assistantContext,
}
messages.value.push(assistantMessage)
sessionMessagesForSend.push(assistantMessage)
publishContextMessage({
sessionId: assistantContext.sessionId,
@@ -515,6 +620,24 @@ export const useChatStore = defineStore('chat', () => {
}
}
async function send(
sendingMessage: string,
options: SendOptions,
) {
const sessionId = activeSessionId.value
const generation = getSessionGeneration(sessionId)
return new Promise<void>((resolve, reject) => {
sendQueue.enqueue({
sendingMessage,
options,
generation,
sessionId,
deferred: { resolve, reject },
})
})
}
return {
sending,
activeSessionId,