fix(stage-tamagotchi): repair chat-list add, switch, and delete (#2086)
Co-authored-by: RainbowBird <git@luoling.moe>
This commit is contained in:
@@ -79,6 +79,7 @@ const builtinToolsStore = useTamagotchiBuiltinToolsStore()
|
|||||||
const mcpToolsStore = useTamagotchiMcpToolsStore()
|
const mcpToolsStore = useTamagotchiMcpToolsStore()
|
||||||
const pluginToolsStore = useTamagotchiPluginToolsStore()
|
const pluginToolsStore = useTamagotchiPluginToolsStore()
|
||||||
const syncedPinia = usePiniaSynced()
|
const syncedPinia = usePiniaSynced()
|
||||||
|
chatSessionStore.setCloudSyncOwnership(syncedPinia.isLeader())
|
||||||
const isSpotlightWindowRoute = initialWindowRoutePath === '/spotlight'
|
const isSpotlightWindowRoute = initialWindowRoutePath === '/spotlight'
|
||||||
const isSettingsWindowRoute = initialWindowRoutePath === '/settings' || initialWindowRoutePath.startsWith('/settings/')
|
const isSettingsWindowRoute = initialWindowRoutePath === '/settings' || initialWindowRoutePath.startsWith('/settings/')
|
||||||
const isEditorWindowRoute = initialWindowRoutePath === '/editor'
|
const isEditorWindowRoute = initialWindowRoutePath === '/editor'
|
||||||
@@ -99,7 +100,8 @@ async function refreshPluginRuntimeTools() {
|
|||||||
|
|
||||||
// Every renderer creates the runtime tool stores because every renderer can
|
// Every renderer creates the runtime tool stores because every renderer can
|
||||||
// become the leader. Only the leader discovers tools and keeps executors.
|
// become the leader. Only the leader discovers tools and keeps executors.
|
||||||
const stopToolLeadershipListener = syncedPinia.onLeadershipChange((isLeader) => {
|
const stopLeadershipListener = syncedPinia.onLeadershipChange((isLeader) => {
|
||||||
|
chatSessionStore.setCloudSyncOwnership(isLeader)
|
||||||
if (!isLeader)
|
if (!isLeader)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -313,7 +315,7 @@ watch(themeColorsHueDynamic, () => {
|
|||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopToolLeadershipListener?.()
|
stopLeadershipListener?.()
|
||||||
fullStageRuntime?.dispose()
|
fullStageRuntime?.dispose()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import type { ChatSessionMeta } from '@proj-airi/stage-ui/types/chat-session'
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
|
||||||
|
import SharedInteractiveArea from '@proj-airi/stage-layouts/components/Layouts/InteractiveArea'
|
||||||
|
import MobileInteractiveArea from '@proj-airi/stage-layouts/components/Layouts/MobileInteractiveArea'
|
||||||
|
import ChatArea from '@proj-airi/stage-layouts/components/Widgets/ChatArea'
|
||||||
|
|
||||||
|
import { PiniaColada } from '@pinia/colada'
|
||||||
|
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||||
|
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
||||||
|
import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { render } from 'vitest-browser-vue'
|
||||||
|
import { userEvent } from 'vitest/browser'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import InteractiveArea from './InteractiveArea.vue'
|
||||||
|
|
||||||
|
function createTestI18n() {
|
||||||
|
return createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
missingWarn: false,
|
||||||
|
fallbackWarn: false,
|
||||||
|
messages: { en: {} },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderArea(component: Component = InteractiveArea) {
|
||||||
|
const sessionB: ChatSessionMeta = {
|
||||||
|
sessionId: 'session-b',
|
||||||
|
userId: 'local',
|
||||||
|
characterId: 'default',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
}
|
||||||
|
const sessionA: ChatSessionMeta = {
|
||||||
|
...sessionB,
|
||||||
|
sessionId: 'session-a',
|
||||||
|
createdAt: 2,
|
||||||
|
updatedAt: 2,
|
||||||
|
}
|
||||||
|
const pinia = createPinia()
|
||||||
|
pinia.state.value = {
|
||||||
|
'chat-session-selection': { activeSessionId: 'session-b' },
|
||||||
|
'chat-session': {
|
||||||
|
sessionMetas: { 'session-a': sessionA, 'session-b': sessionB },
|
||||||
|
sessionMessages: {
|
||||||
|
'session-a': [{ id: 'system-a', role: 'system', content: 'session A prompt' }],
|
||||||
|
'session-b': [{ id: 'system', role: 'system', content: 'system prompt' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const router = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [{ path: '/', component: { template: '<div />' } }],
|
||||||
|
})
|
||||||
|
await router.push('/')
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
const screen = await render(component, {
|
||||||
|
global: { plugins: [pinia, PiniaColada, createTestI18n(), router] },
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
chat: useChatStore(pinia),
|
||||||
|
chatSession: useChatSessionStore(pinia),
|
||||||
|
chatStream: useChatStreamStore(pinia),
|
||||||
|
screen,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitDraft(screen: Awaited<ReturnType<typeof renderArea>>['screen'], draft: string) {
|
||||||
|
const input = screen.getByRole('textbox')
|
||||||
|
await userEvent.fill(input, draft)
|
||||||
|
await userEvent.click(input)
|
||||||
|
await userEvent.keyboard('{Enter}')
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('interactive area synchronized state', () => {
|
||||||
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743121861
|
||||||
|
it('renders the active synchronized stream through the real chat history for Issue #2085', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// A follower received the leader-owned active stream in the real chat
|
||||||
|
// store, but InteractiveArea passed its unrelated foreground stream to
|
||||||
|
// ChatHistory. Mocking either store or component hid that broken binding.
|
||||||
|
const { chat, chatStream, screen } = await renderArea()
|
||||||
|
chat.$patch({
|
||||||
|
activeSendSessionId: 'session-b',
|
||||||
|
activeStreamingMessage: {
|
||||||
|
id: 'follower-b-stream',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Follower B live response',
|
||||||
|
slices: [{ type: 'text', text: 'Follower B live response' }],
|
||||||
|
tool_results: [],
|
||||||
|
createdAt: 2,
|
||||||
|
},
|
||||||
|
sending: true,
|
||||||
|
})
|
||||||
|
chatStream.$patch({
|
||||||
|
streamingMessage: {
|
||||||
|
id: 'leader-a-stream',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Leader A foreground response',
|
||||||
|
slices: [{ type: 'text', text: 'Leader A foreground response' }],
|
||||||
|
tool_results: [],
|
||||||
|
createdAt: 3,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
await expect.element(screen.getByText('Follower B live response')).toBeVisible()
|
||||||
|
await expect.element(screen.getByText('Leader A foreground response')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743309235
|
||||||
|
it('scopes the mobile synchronized stream to its local session for Issue #2085', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// MobileInteractiveArea passed the synchronized global sending state and
|
||||||
|
// foreground stream directly to ChatHistory. A mobile window on session B
|
||||||
|
// therefore rendered the live response from a send targeting session A.
|
||||||
|
const { chat, chatStream, screen } = await renderArea(MobileInteractiveArea)
|
||||||
|
chat.$patch({
|
||||||
|
activeSendSessionId: 'session-a',
|
||||||
|
activeStreamingMessage: {
|
||||||
|
id: 'session-a-stream',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Session A live response',
|
||||||
|
slices: [{ type: 'text', text: 'Session A live response' }],
|
||||||
|
tool_results: [],
|
||||||
|
createdAt: 2,
|
||||||
|
},
|
||||||
|
sending: true,
|
||||||
|
})
|
||||||
|
chatStream.$patch({
|
||||||
|
streamingMessage: {
|
||||||
|
id: 'session-a-foreground',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Session A live response',
|
||||||
|
slices: [{ type: 'text', text: 'Session A live response' }],
|
||||||
|
tool_results: [],
|
||||||
|
createdAt: 2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
await expect.element(screen.getByText('Session A live response')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
chat.$patch({
|
||||||
|
activeSendSessionId: 'session-b',
|
||||||
|
activeStreamingMessage: {
|
||||||
|
id: 'session-b-stream',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Session B live response',
|
||||||
|
slices: [{ type: 'text', text: 'Session B live response' }],
|
||||||
|
tool_results: [],
|
||||||
|
createdAt: 3,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
await expect.element(screen.getByText('Session B live response')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743366443
|
||||||
|
it('scopes the stage-web desktop synchronized stream to its local session for Issue #2085', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// The shared desktop layout derived sending from the target session but
|
||||||
|
// still passed the leader foreground stream to ChatHistory. A web window
|
||||||
|
// on B could therefore append A's live response.
|
||||||
|
const { chat, chatStream, screen } = await renderArea(SharedInteractiveArea)
|
||||||
|
chat.$patch({
|
||||||
|
activeSendSessionId: 'session-b',
|
||||||
|
activeStreamingMessage: {
|
||||||
|
id: 'session-b-web-stream',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Session B web response',
|
||||||
|
slices: [{ type: 'text', text: 'Session B web response' }],
|
||||||
|
tool_results: [],
|
||||||
|
createdAt: 2,
|
||||||
|
},
|
||||||
|
sending: true,
|
||||||
|
})
|
||||||
|
chatStream.$patch({
|
||||||
|
streamingMessage: {
|
||||||
|
id: 'session-a-web-foreground',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Session A foreground response',
|
||||||
|
slices: [{ type: 'text', text: 'Session A foreground response' }],
|
||||||
|
tool_results: [],
|
||||||
|
createdAt: 3,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
await expect.element(screen.getByText('Session B web response')).toBeVisible()
|
||||||
|
await expect.element(screen.getByText('Session A foreground response')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes a stage-web send through the synchronized chat action', async () => {
|
||||||
|
const { chat, screen } = await renderArea(SharedInteractiveArea)
|
||||||
|
const send = vi.spyOn(chat, 'send').mockResolvedValueOnce({ messages: [], sessionId: 'session-b' })
|
||||||
|
|
||||||
|
await submitDraft(screen, 'web follower message')
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(send).toHaveBeenCalledWith({
|
||||||
|
sessionId: 'session-b',
|
||||||
|
text: 'web follower message',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes a mobile send through the synchronized chat action', async () => {
|
||||||
|
const { chat, screen } = await renderArea(MobileInteractiveArea)
|
||||||
|
const send = vi.spyOn(chat, 'send').mockResolvedValueOnce({ messages: [], sessionId: 'session-b' })
|
||||||
|
|
||||||
|
await submitDraft(screen, 'mobile follower message')
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(send).toHaveBeenCalledWith({
|
||||||
|
sessionId: 'session-b',
|
||||||
|
text: 'mobile follower message',
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3755530944
|
||||||
|
it('keeps a failed mobile draft out of a newly selected session for Issue #2085', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// Shared layouts restored a rejected send into their component-wide input
|
||||||
|
// without checking whether the window still displayed the target session.
|
||||||
|
const { chat, chatSession, screen } = await renderArea(MobileInteractiveArea)
|
||||||
|
let rejectSend: ((error: Error) => void) | undefined
|
||||||
|
vi.spyOn(chat, 'send').mockImplementationOnce(() => new Promise((_resolve, reject) => {
|
||||||
|
rejectSend = reject
|
||||||
|
}))
|
||||||
|
|
||||||
|
const input = await submitDraft(screen, 'mobile draft from B')
|
||||||
|
chatSession.activeSessionId = 'session-a'
|
||||||
|
rejectSend?.(new Error('send failed'))
|
||||||
|
|
||||||
|
await expect.element(input).toHaveValue('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not restore a deleted-session draft in the shared chat widget', async () => {
|
||||||
|
const { chat, screen } = await renderArea(ChatArea)
|
||||||
|
let rejectSend: ((error: Error) => void) | undefined
|
||||||
|
vi.spyOn(chat, 'send').mockImplementationOnce(() => new Promise((_resolve, reject) => {
|
||||||
|
rejectSend = reject
|
||||||
|
}))
|
||||||
|
|
||||||
|
const input = await submitDraft(screen, 'deleted web draft')
|
||||||
|
rejectSend?.(new Error('Chat session was removed before send completed'))
|
||||||
|
|
||||||
|
await expect.element(input).toHaveValue('')
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3628804992
|
||||||
|
it('does not restore a failed draft into a newly selected session for Issue #2085', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// Failure recovery used the reactive selection instead of the session
|
||||||
|
// captured by the send, so a late rejection could move a draft.
|
||||||
|
const { chat, chatSession, screen } = await renderArea()
|
||||||
|
let rejectSend: ((error: Error) => void) | undefined
|
||||||
|
vi.spyOn(chat, 'send').mockImplementationOnce(() => new Promise((_resolve, reject) => {
|
||||||
|
rejectSend = reject
|
||||||
|
}))
|
||||||
|
|
||||||
|
const input = await submitDraft(screen, 'send from B')
|
||||||
|
await expect.element(input).toHaveValue('')
|
||||||
|
chatSession.activeSessionId = 'session-a'
|
||||||
|
rejectSend?.(new Error('hydrate failed'))
|
||||||
|
|
||||||
|
await expect.element(input).toHaveValue('')
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3629004140
|
||||||
|
it('restores a failed draft when its captured session is still active for Issue #2085', async () => {
|
||||||
|
const { chat, screen } = await renderArea()
|
||||||
|
vi.spyOn(chat, 'send').mockRejectedValueOnce(new Error('send failed'))
|
||||||
|
|
||||||
|
const input = await submitDraft(screen, 'retry this draft')
|
||||||
|
await expect.element(input).toHaveValue('retry this draft')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a newer draft when an earlier send fails', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// Failure recovery replaced the textarea unconditionally. Text entered
|
||||||
|
// while the request was pending was lost with its attachment previews.
|
||||||
|
const { chat, screen } = await renderArea()
|
||||||
|
let rejectSend: ((error: Error) => void) | undefined
|
||||||
|
vi.spyOn(chat, 'send').mockImplementationOnce(() => new Promise((_resolve, reject) => {
|
||||||
|
rejectSend = reject
|
||||||
|
}))
|
||||||
|
|
||||||
|
const input = await submitDraft(screen, 'first draft')
|
||||||
|
await userEvent.fill(input, 'newer draft')
|
||||||
|
rejectSend?.(new Error('send failed'))
|
||||||
|
|
||||||
|
await expect.element(input).toHaveValue('first draft\nnewer draft')
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743366446
|
||||||
|
it('discards a queued draft when deletion cancels its send for Issue #2085', async () => {
|
||||||
|
const { chat, screen } = await renderArea()
|
||||||
|
let rejectSend: ((error: Error) => void) | undefined
|
||||||
|
vi.spyOn(chat, 'send').mockImplementationOnce(() => new Promise((_resolve, reject) => {
|
||||||
|
rejectSend = reject
|
||||||
|
}))
|
||||||
|
|
||||||
|
const input = await submitDraft(screen, 'discard this deleted draft')
|
||||||
|
rejectSend?.(new Error('Chat session was reset before send could start'))
|
||||||
|
|
||||||
|
await expect.element(input).toHaveValue('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import type { ChatToolCallRendererRegistry } from '@proj-airi/stage-ui/components'
|
import type { ChatToolCallRendererRegistry } from '@proj-airi/stage-ui/components'
|
||||||
import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat'
|
import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat'
|
||||||
|
|
||||||
|
import { errorMessageFrom } from '@moeru/std'
|
||||||
import { useStopSpeakingButton } from '@proj-airi/stage-layouts/composables/useStopSpeakingButton'
|
import { useStopSpeakingButton } from '@proj-airi/stage-layouts/composables/useStopSpeakingButton'
|
||||||
import { ChatHistory, JournalPreviewModal } from '@proj-airi/stage-ui/components'
|
import { ChatHistory, JournalPreviewModal } from '@proj-airi/stage-ui/components'
|
||||||
import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics'
|
import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics'
|
||||||
@@ -37,9 +38,9 @@ const backgroundStore = useBackgroundStore()
|
|||||||
const journalPreviewStore = useJournalPreviewStore()
|
const journalPreviewStore = useJournalPreviewStore()
|
||||||
const airiCardStore = useAiriCardStore()
|
const airiCardStore = useAiriCardStore()
|
||||||
|
|
||||||
const { messages } = storeToRefs(chatSession)
|
const { activeSessionId, messages } = storeToRefs(chatSession)
|
||||||
const { streamingMessage } = storeToRefs(chatStream)
|
const { streamingMessage } = storeToRefs(chatStream)
|
||||||
const { sending } = storeToRefs(chatStore)
|
const { activeSendSessionId, activeStreamingMessage, sending } = storeToRefs(chatStore)
|
||||||
const { activeCard, activeCardId } = storeToRefs(airiCardStore)
|
const { activeCard, activeCardId } = storeToRefs(airiCardStore)
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { openImagePreview } = journalPreviewStore
|
const { openImagePreview } = journalPreviewStore
|
||||||
@@ -88,6 +89,9 @@ async function handleSend() {
|
|||||||
|
|
||||||
const textToSend = messageInput.value
|
const textToSend = messageInput.value
|
||||||
const attachmentsToSend = attachments.value.map(att => ({ ...att }))
|
const attachmentsToSend = attachments.value.map(att => ({ ...att }))
|
||||||
|
// The active session can change while the cross-window request is pending.
|
||||||
|
// Keep one correlation key for both the send and its failure recovery.
|
||||||
|
const targetSessionId = chatSession.activeSessionId
|
||||||
|
|
||||||
// optimistic clear
|
// optimistic clear
|
||||||
messageInput.value = ''
|
messageInput.value = ''
|
||||||
@@ -95,7 +99,7 @@ async function handleSend() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await chatStore.send({
|
await chatStore.send({
|
||||||
sessionId: chatSession.activeSessionId,
|
sessionId: targetSessionId,
|
||||||
text: textToSend,
|
text: textToSend,
|
||||||
attachments: attachmentsToSend,
|
attachments: attachmentsToSend,
|
||||||
tools: artistryToolReferences,
|
tools: artistryToolReferences,
|
||||||
@@ -103,10 +107,21 @@ async function handleSend() {
|
|||||||
|
|
||||||
attachmentsToSend.forEach(att => URL.revokeObjectURL(att.url))
|
attachmentsToSend.forEach(att => URL.revokeObjectURL(att.url))
|
||||||
}
|
}
|
||||||
catch {
|
catch (error) {
|
||||||
// restore on failure
|
const errorMessage = errorMessageFrom(error) ?? String(error)
|
||||||
messageInput.value = textToSend
|
const wasCancelledForDeletedSession
|
||||||
attachments.value = attachmentsToSend
|
= errorMessage.includes('Chat session was reset before send could start')
|
||||||
|
|| errorMessage.includes('Chat session was removed before send completed')
|
||||||
|
if (!wasCancelledForDeletedSession && chatSession.activeSessionId === targetSessionId) {
|
||||||
|
const currentDraft = messageInput.value
|
||||||
|
messageInput.value = currentDraft ? `${textToSend}\n${currentDraft}` : textToSend
|
||||||
|
attachments.value = [...attachmentsToSend, ...attachments.value]
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// This window no longer owns a visible attachment preview, so its Blob
|
||||||
|
// URLs must be released instead of surviving until the window closes.
|
||||||
|
attachmentsToSend.forEach(attachment => URL.revokeObjectURL(attachment.url))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +212,10 @@ watch(sendMode, () => {
|
|||||||
|
|
||||||
const historyMessages = computed(() => messages.value as unknown as ChatHistoryItem[])
|
const historyMessages = computed(() => messages.value as unknown as ChatHistoryItem[])
|
||||||
const assistantLabel = computed(() => activeCard.value?.name?.trim() || undefined)
|
const assistantLabel = computed(() => activeCard.value?.name?.trim() || undefined)
|
||||||
|
const isActiveSessionSending = computed(() => sending.value && activeSendSessionId.value === activeSessionId.value)
|
||||||
|
const visibleStreamingMessage = computed(() => activeSendSessionId.value === activeSessionId.value
|
||||||
|
? activeStreamingMessage.value
|
||||||
|
: streamingMessage.value)
|
||||||
|
|
||||||
async function handleDeleteMessage(index: number) {
|
async function handleDeleteMessage(index: number) {
|
||||||
const message = messages.value[index]
|
const message = messages.value[index]
|
||||||
@@ -252,8 +271,8 @@ async function handleCleanupMessages() {
|
|||||||
<ChatHistory
|
<ChatHistory
|
||||||
:messages="historyMessages"
|
:messages="historyMessages"
|
||||||
:assistant-label="assistantLabel"
|
:assistant-label="assistantLabel"
|
||||||
:sending="sending"
|
:sending="isActiveSessionSending"
|
||||||
:streaming-message="streamingMessage"
|
:streaming-message="visibleStreamingMessage"
|
||||||
:tool-call-renderers="toolCallRenderers"
|
:tool-call-renderers="toolCallRenderers"
|
||||||
@delete-message="handleDeleteMessage($event.index)"
|
@delete-message="handleDeleteMessage($event.index)"
|
||||||
@retry-message="handleRetryMessage($event.index)"
|
@retry-message="handleRetryMessage($event.index)"
|
||||||
|
|||||||
@@ -3,19 +3,44 @@ import { cwd } from 'node:process'
|
|||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import Info from 'unplugin-info/vite'
|
import Info from 'unplugin-info/vite'
|
||||||
|
|
||||||
|
import { playwright } from '@vitest/browser-playwright'
|
||||||
import { loadEnv } from 'vite'
|
import { loadEnv } from 'vite'
|
||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
root: import.meta.dirname,
|
||||||
plugins: [
|
plugins: [
|
||||||
Info(),
|
Info(),
|
||||||
vue(),
|
vue(),
|
||||||
],
|
],
|
||||||
test: {
|
test: {
|
||||||
env: loadEnv('test', cwd(), ''),
|
env: loadEnv('test', cwd(), ''),
|
||||||
include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'],
|
projects: [
|
||||||
exclude: ['**/node_modules/**', '**/.git/**'],
|
{
|
||||||
fileParallelism: false,
|
extends: true,
|
||||||
maxWorkers: 1,
|
test: {
|
||||||
|
name: 'node',
|
||||||
|
include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'],
|
||||||
|
exclude: ['src/**/*.browser.test.ts', '**/node_modules/**', '**/.git/**'],
|
||||||
|
fileParallelism: false,
|
||||||
|
maxWorkers: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
extends: true,
|
||||||
|
test: {
|
||||||
|
name: 'browser',
|
||||||
|
include: ['src/**/*.browser.test.ts'],
|
||||||
|
exclude: ['**/node_modules/**', '**/.git/**'],
|
||||||
|
browser: {
|
||||||
|
enabled: true,
|
||||||
|
provider: playwright(),
|
||||||
|
instances: [
|
||||||
|
{ browser: 'chromium' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { cwd } from 'node:process'
|
||||||
|
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import Info from 'unplugin-info/vite'
|
||||||
|
|
||||||
|
import { loadEnv } from 'vite'
|
||||||
|
import { defineProject } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineProject({
|
||||||
|
root: import.meta.dirname,
|
||||||
|
plugins: [Info(), vue()],
|
||||||
|
test: {
|
||||||
|
name: 'stage-tamagotchi:node',
|
||||||
|
env: loadEnv('test', cwd(), ''),
|
||||||
|
include: ['src/**/*.test.ts', 'scripts/**/*.test.ts'],
|
||||||
|
exclude: ['src/**/*.browser.test.ts', '**/node_modules/**', '**/.git/**'],
|
||||||
|
fileParallelism: false,
|
||||||
|
maxWorkers: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -3,6 +3,7 @@ import { OnboardingDialog, OnboardingStepAnalyticsNotice, ToasterRoot } from '@p
|
|||||||
import { useInferencePreload } from '@proj-airi/stage-ui/composables'
|
import { useInferencePreload } from '@proj-airi/stage-ui/composables'
|
||||||
import { useAuthProviderSync } from '@proj-airi/stage-ui/composables/use-auth-provider-sync'
|
import { useAuthProviderSync } from '@proj-airi/stage-ui/composables/use-auth-provider-sync'
|
||||||
import { initializeAnalytics, isAnalyticsAvailableInBuild } from '@proj-airi/stage-ui/libs/analytics'
|
import { initializeAnalytics, isAnalyticsAvailableInBuild } from '@proj-airi/stage-ui/libs/analytics'
|
||||||
|
import { usePiniaSynced } from '@proj-airi/stage-ui/libs/pinia'
|
||||||
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character'
|
import { useCharacterOrchestratorStore } from '@proj-airi/stage-ui/stores/character'
|
||||||
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
||||||
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
|
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
|
||||||
@@ -33,6 +34,9 @@ const settingsStore = useSettings()
|
|||||||
const settings = storeToRefs(settingsStore)
|
const settings = storeToRefs(settingsStore)
|
||||||
const onboardingStore = useOnboardingStore()
|
const onboardingStore = useOnboardingStore()
|
||||||
const chatSessionStore = useChatSessionStore()
|
const chatSessionStore = useChatSessionStore()
|
||||||
|
const syncedPinia = usePiniaSynced()
|
||||||
|
chatSessionStore.setCloudSyncOwnership(syncedPinia.isLeader())
|
||||||
|
const stopLeadershipListener = syncedPinia.onLeadershipChange(isLeader => chatSessionStore.setCloudSyncOwnership(isLeader))
|
||||||
const serverChannelStore = useModsServerChannelStore()
|
const serverChannelStore = useModsServerChannelStore()
|
||||||
const characterOrchestratorStore = useCharacterOrchestratorStore()
|
const characterOrchestratorStore = useCharacterOrchestratorStore()
|
||||||
const settingsAudioDeviceStore = useSettingsAudioDevice()
|
const settingsAudioDeviceStore = useSettingsAudioDevice()
|
||||||
@@ -105,6 +109,7 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
stopLeadershipListener()
|
||||||
contextBridgeStore.dispose()
|
contextBridgeStore.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -34,7 +34,8 @@
|
|||||||
"build:packages": "turbo run build -F=\"./packages/*\"",
|
"build:packages": "turbo run build -F=\"./packages/*\"",
|
||||||
"build:engines": "turbo run build -F=\"./engines/*\"",
|
"build:engines": "turbo run build -F=\"./engines/*\"",
|
||||||
"test": "vitest --coverage",
|
"test": "vitest --coverage",
|
||||||
"test:run": "vitest run && pnpm run test-audio-pipelines-transcribe:run && pnpm run test-ui:run",
|
"test:run": "vitest run && pnpm run test-stage-tamagotchi:run && pnpm run test-audio-pipelines-transcribe:run && pnpm run test-ui:run",
|
||||||
|
"test-stage-tamagotchi:run": "vitest run --config apps/stage-tamagotchi/vitest.config.ts --project browser",
|
||||||
"test-audio-pipelines-transcribe:run": "vitest run --config packages/audio-pipelines-transcribe/vitest.config.ts",
|
"test-audio-pipelines-transcribe:run": "vitest run --config packages/audio-pipelines-transcribe/vitest.config.ts",
|
||||||
"test-ui:run": "vitest run --config packages/stage-ui/vitest.config.ts",
|
"test-ui:run": "vitest run --config packages/stage-ui/vitest.config.ts",
|
||||||
"lint": "moeru-lint .",
|
"lint": "moeru-lint .",
|
||||||
|
|||||||
@@ -142,11 +142,6 @@ function createHarness() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @example
|
|
||||||
* const runtime = createChatOrchestratorRuntime(deps)
|
|
||||||
* await runtime.ingest('hello', { model, chatProvider })
|
|
||||||
*/
|
|
||||||
describe('createChatOrchestratorRuntime', () => {
|
describe('createChatOrchestratorRuntime', () => {
|
||||||
// ROOT CAUSE:
|
// ROOT CAUSE:
|
||||||
//
|
//
|
||||||
@@ -290,10 +285,6 @@ describe('createChatOrchestratorRuntime', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* @example
|
|
||||||
* Hook order and prompt composition stay compatible with the stage-ui facade.
|
|
||||||
*/
|
|
||||||
it('keeps hook order and appends context prompt to the latest user message', async () => {
|
it('keeps hook order and appends context prompt to the latest user message', async () => {
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
harness.contextSnapshot['system:weather'] = [
|
harness.contextSnapshot['system:weather'] = [
|
||||||
@@ -447,11 +438,6 @@ describe('createChatOrchestratorRuntime', () => {
|
|||||||
expect(legacyUserMessage.createdAt).toBe(new Date(2026, 3, 25, 18, 47).getTime())
|
expect(legacyUserMessage.createdAt).toBe(new Date(2026, 3, 25, 18, 47).getTime())
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* @example
|
|
||||||
* deps.getSystemPromptSupplement() returns tool guidance.
|
|
||||||
* The runtime appends it to the existing provider system message.
|
|
||||||
*/
|
|
||||||
it('appends system prompt supplement to the provider system message', async () => {
|
it('appends system prompt supplement to the provider system message', async () => {
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
let composedMessages: Message[] = []
|
let composedMessages: Message[] = []
|
||||||
@@ -473,11 +459,6 @@ describe('createChatOrchestratorRuntime', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* @example
|
|
||||||
* A session has only user history.
|
|
||||||
* The runtime creates a provider system message for supplemental guidance.
|
|
||||||
*/
|
|
||||||
it('creates a system message when only a system prompt supplement is available', async () => {
|
it('creates a system message when only a system prompt supplement is available', async () => {
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
let composedMessages: Message[] = []
|
let composedMessages: Message[] = []
|
||||||
@@ -501,10 +482,6 @@ describe('createChatOrchestratorRuntime', () => {
|
|||||||
expect(composedMessages[1]).toMatchObject({ role: 'user' })
|
expect(composedMessages[1]).toMatchObject({ role: 'user' })
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* @example
|
|
||||||
* Runtime telemetry callbacks expose client-visible latency milestones.
|
|
||||||
*/
|
|
||||||
it('emits telemetry milestones for a successful voice-backed message round', async () => {
|
it('emits telemetry milestones for a successful voice-backed message round', async () => {
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
harness.monotonicNow.set([100, 150, 250, 400, 460])
|
harness.monotonicNow.set([100, 150, 250, 400, 460])
|
||||||
@@ -626,10 +603,6 @@ describe('createChatOrchestratorRuntime', () => {
|
|||||||
expect(harness.telemetry.messageRound).toHaveLength(2)
|
expect(harness.telemetry.messageRound).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* @example
|
|
||||||
* await expect(runtime.ingest('hello', { model, chatProvider })).rejects.toThrow('provider rejected')
|
|
||||||
*/
|
|
||||||
it('emits chat activation failure telemetry without raw provider messages', async () => {
|
it('emits chat activation failure telemetry without raw provider messages', async () => {
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
harness.stream.mockRejectedValueOnce(new Error('provider rejected with sensitive details'))
|
harness.stream.mockRejectedValueOnce(new Error('provider rejected with sensitive details'))
|
||||||
@@ -696,10 +669,6 @@ describe('createChatOrchestratorRuntime', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* @example
|
|
||||||
* Cancelling a queued send rejects only pending work that has not started.
|
|
||||||
*/
|
|
||||||
it('rejects cancelled queued sends before they start', async () => {
|
it('rejects cancelled queued sends before they start', async () => {
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
let releaseFirstSend: (() => void) | undefined
|
let releaseFirstSend: (() => void) | undefined
|
||||||
@@ -731,10 +700,57 @@ describe('createChatOrchestratorRuntime', () => {
|
|||||||
await firstSend
|
await firstSend
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3714754876
|
||||||
* @example
|
it('suppresses completion hooks when an active send session is deleted for Issue #2085', async () => {
|
||||||
* A queued send rejects if its captured session generation becomes stale.
|
// ROOT CAUSE:
|
||||||
*/
|
//
|
||||||
|
// Generation checks protected message mutation during a stream, but the
|
||||||
|
// runtime still emitted completion hooks and success analytics after the
|
||||||
|
// provider returned for a deleted session.
|
||||||
|
const harness = createHarness()
|
||||||
|
const completionHook = vi.fn()
|
||||||
|
harness.runtime.hooks.onStreamEnd(completionHook)
|
||||||
|
harness.runtime.hooks.onAssistantResponseEnd(completionHook)
|
||||||
|
harness.runtime.hooks.onAfterSend(completionHook)
|
||||||
|
harness.runtime.hooks.onAssistantMessage(completionHook)
|
||||||
|
harness.runtime.hooks.onChatTurnComplete(completionHook)
|
||||||
|
|
||||||
|
let finishStream: (() => void) | undefined
|
||||||
|
harness.stream.mockImplementationOnce(async (_model, _chatProvider, _messages, options) => {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
finishStream = resolve
|
||||||
|
})
|
||||||
|
options?.onUsage?.({
|
||||||
|
inputTokens: 1,
|
||||||
|
outputTokens: 1,
|
||||||
|
totalTokens: 2,
|
||||||
|
source: 'reported',
|
||||||
|
})
|
||||||
|
await options?.onStreamEvent?.({ type: 'text-delta', text: 'deleted reply' })
|
||||||
|
await options?.onStreamEvent?.({ type: 'finish', finishReason: 'stop' })
|
||||||
|
})
|
||||||
|
|
||||||
|
const pendingSend = harness.runtime.ingest('delete this chat', {
|
||||||
|
model: 'gpt-test',
|
||||||
|
chatProvider: provider,
|
||||||
|
})
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(harness.stream).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
harness.generation.set(2)
|
||||||
|
finishStream?.()
|
||||||
|
await pendingSend
|
||||||
|
|
||||||
|
expect(completionHook).not.toHaveBeenCalled()
|
||||||
|
expect(harness.assistantAppended).toEqual([])
|
||||||
|
expect(harness.assistantTurns).toEqual([])
|
||||||
|
expect(harness.telemetry.assistantResponseRendered).toEqual([])
|
||||||
|
expect(harness.telemetry.llmGeneration).toEqual([])
|
||||||
|
expect(harness.telemetry.messageRound).toEqual([])
|
||||||
|
expect(harness.telemetry.chatActivationSucceeded).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
it('rejects stale generation sends before they start', async () => {
|
it('rejects stale generation sends before they start', async () => {
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
let releaseFirstSend: (() => void) | undefined
|
let releaseFirstSend: (() => void) | undefined
|
||||||
@@ -767,17 +783,14 @@ describe('createChatOrchestratorRuntime', () => {
|
|||||||
expect(harness.stream).toHaveBeenCalledTimes(1)
|
expect(harness.stream).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* @example
|
|
||||||
* runtime.setSending(true)
|
|
||||||
* expect(runtime.getSending()).toBe(true)
|
|
||||||
*/
|
|
||||||
it('keeps sending externally writable for UI facades', () => {
|
it('keeps sending externally writable for UI facades', () => {
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
|
|
||||||
harness.runtime.setSending(true)
|
harness.runtime.setSending(true)
|
||||||
expect(harness.runtime.getSending()).toBe(true)
|
expect(harness.runtime.getSending()).toBe(true)
|
||||||
expect(harness.stateChanges.at(-1)).toEqual({
|
expect(harness.stateChanges.at(-1)).toEqual({
|
||||||
|
activeSendSessionId: 'session-1',
|
||||||
|
activeStreamingMessage: undefined,
|
||||||
sending: true,
|
sending: true,
|
||||||
pendingQueuedSendCount: 0,
|
pendingQueuedSendCount: 0,
|
||||||
})
|
})
|
||||||
@@ -785,16 +798,66 @@ describe('createChatOrchestratorRuntime', () => {
|
|||||||
harness.runtime.setSending(false)
|
harness.runtime.setSending(false)
|
||||||
expect(harness.runtime.getSending()).toBe(false)
|
expect(harness.runtime.getSending()).toBe(false)
|
||||||
expect(harness.stateChanges.at(-1)).toEqual({
|
expect(harness.stateChanges.at(-1)).toEqual({
|
||||||
|
activeSendSessionId: undefined,
|
||||||
|
activeStreamingMessage: undefined,
|
||||||
|
sending: false,
|
||||||
|
pendingQueuedSendCount: 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/moeru-ai/airi/issues/2085
|
||||||
|
it('reports the queued send target while a background session is sending for Issue #2085', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// Runtime state exposed only a global sending boolean. A window-level sync
|
||||||
|
// layer therefore had to infer the owner from the authority's visible
|
||||||
|
// session, which is wrong when a follower targets a background session.
|
||||||
|
const harness = createHarness()
|
||||||
|
let finishSend: (() => void) | undefined
|
||||||
|
harness.stream.mockImplementationOnce(async (_model, _chatProvider, _messages, options) => {
|
||||||
|
await options?.onStreamEvent?.({ type: 'text-delta', text: 'background reply' })
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
finishSend = resolve
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const pendingSend = harness.runtime.ingest('background request', {
|
||||||
|
model: 'gpt-test',
|
||||||
|
chatProvider: provider,
|
||||||
|
}, 'session-2')
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(harness.stateChanges).toContainEqual(expect.objectContaining({
|
||||||
|
activeSendSessionId: 'session-2',
|
||||||
|
activeStreamingMessage: expect.objectContaining({
|
||||||
|
role: 'assistant',
|
||||||
|
createdAt: expect.any(Number),
|
||||||
|
}),
|
||||||
|
sending: true,
|
||||||
|
pendingQueuedSendCount: 0,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(harness.stream).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(harness.stateChanges).toContainEqual(expect.objectContaining({
|
||||||
|
activeSendSessionId: 'session-2',
|
||||||
|
activeStreamingMessage: expect.objectContaining({ content: expect.stringContaining('background') }),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
finishSend?.()
|
||||||
|
await pendingSend
|
||||||
|
|
||||||
|
expect(harness.stateChanges.at(-1)).toEqual({
|
||||||
|
activeSendSessionId: undefined,
|
||||||
|
activeStreamingMessage: undefined,
|
||||||
sending: false,
|
sending: false,
|
||||||
pendingQueuedSendCount: 0,
|
pendingQueuedSendCount: 0,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* @example
|
|
||||||
* const snapshot = runtime.getPendingQueuedSendSnapshot()
|
|
||||||
* expect(snapshot[0].inputType).toBe('input:text')
|
|
||||||
*/
|
|
||||||
it('returns pending queued send snapshots with public fields', async () => {
|
it('returns pending queued send snapshots with public fields', async () => {
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
let releaseFirstSend: (() => void) | undefined
|
let releaseFirstSend: (() => void) | undefined
|
||||||
@@ -852,10 +915,6 @@ describe('createChatOrchestratorRuntime', () => {
|
|||||||
await firstSend
|
await firstSend
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* @example
|
|
||||||
* Attachments, reasoning deltas, and tool events update the assistant builder.
|
|
||||||
*/
|
|
||||||
it('handles attachments, reasoning deltas, tool events, and assistant finalization', async () => {
|
it('handles attachments, reasoning deltas, tool events, and assistant finalization', async () => {
|
||||||
const harness = createHarness()
|
const harness = createHarness()
|
||||||
let composedMessages: Message[] = []
|
let composedMessages: Message[] = []
|
||||||
|
|||||||
@@ -154,6 +154,10 @@ export interface ChatOrchestratorPromptProjection {
|
|||||||
export interface ChatOrchestratorRuntimeState {
|
export interface ChatOrchestratorRuntimeState {
|
||||||
/** Whether the runtime currently owns an active send. */
|
/** Whether the runtime currently owns an active send. */
|
||||||
sending: boolean
|
sending: boolean
|
||||||
|
/** Session that owns the active send; undefined while the queue is idle. */
|
||||||
|
activeSendSessionId?: string
|
||||||
|
/** Latest assistant stream snapshot owned by the active send session. */
|
||||||
|
activeStreamingMessage?: StreamingAssistantMessage
|
||||||
/** Number of sends waiting behind the active one. */
|
/** Number of sends waiting behind the active one. */
|
||||||
pendingQueuedSendCount: number
|
pendingQueuedSendCount: number
|
||||||
}
|
}
|
||||||
@@ -350,19 +354,29 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
|
|||||||
const unwrapMessage = deps.unwrapMessage ?? (<T>(message: T) => message)
|
const unwrapMessage = deps.unwrapMessage ?? (<T>(message: T) => message)
|
||||||
|
|
||||||
let sending = false
|
let sending = false
|
||||||
|
let activeSendSessionId: string | undefined
|
||||||
|
let activeStreamingMessage: StreamingAssistantMessage | undefined
|
||||||
let pendingQueuedSends: QueuedSend[] = []
|
let pendingQueuedSends: QueuedSend[] = []
|
||||||
|
|
||||||
function emitStateChange() {
|
function emitStateChange() {
|
||||||
deps.onStateChange?.({
|
deps.onStateChange?.({
|
||||||
sending,
|
sending,
|
||||||
|
activeSendSessionId,
|
||||||
|
activeStreamingMessage,
|
||||||
pendingQueuedSendCount: pendingQueuedSends.length,
|
pendingQueuedSendCount: pendingQueuedSends.length,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSending(next: boolean) {
|
function setSending(next: boolean) {
|
||||||
if (sending === next)
|
const nextActiveSendSessionId = next
|
||||||
|
? activeSendSessionId ?? deps.getActiveSessionId()
|
||||||
|
: undefined
|
||||||
|
if (sending === next && activeSendSessionId === nextActiveSendSessionId)
|
||||||
return
|
return
|
||||||
sending = next
|
sending = next
|
||||||
|
activeSendSessionId = nextActiveSendSessionId
|
||||||
|
if (!next)
|
||||||
|
activeStreamingMessage = undefined
|
||||||
emitStateChange()
|
emitStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +384,22 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
|
|||||||
return sessionId === deps.getActiveSessionId()
|
return sessionId === deps.getActiveSessionId()
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchForegroundStream(sessionId: string, message: StreamingAssistantMessage) {
|
function beginStream(sessionId: string, message: StreamingAssistantMessage) {
|
||||||
|
sending = true
|
||||||
|
activeSendSessionId = sessionId
|
||||||
|
activeStreamingMessage = cloneStreamingMessage(message)
|
||||||
|
emitStateChange()
|
||||||
|
|
||||||
|
if (isForegroundSession(sessionId))
|
||||||
|
deps.foregroundStream.patch(cloneStreamingMessage(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStream(sessionId: string, message: StreamingAssistantMessage) {
|
||||||
|
if (sessionId === activeSendSessionId) {
|
||||||
|
activeStreamingMessage = cloneStreamingMessage(message)
|
||||||
|
emitStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
if (isForegroundSession(sessionId))
|
if (isForegroundSession(sessionId))
|
||||||
deps.foregroundStream.patch(cloneStreamingMessage(message))
|
deps.foregroundStream.patch(cloneStreamingMessage(message))
|
||||||
}
|
}
|
||||||
@@ -481,8 +510,6 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
|
|||||||
if (shouldAbort())
|
if (shouldAbort())
|
||||||
return
|
return
|
||||||
|
|
||||||
setSending(true)
|
|
||||||
|
|
||||||
const buildingMessage: StreamingAssistantMessage = {
|
const buildingMessage: StreamingAssistantMessage = {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: '',
|
content: '',
|
||||||
@@ -491,7 +518,7 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
|
|||||||
createdAt: now(),
|
createdAt: now(),
|
||||||
id: assistantMessageId,
|
id: assistantMessageId,
|
||||||
}
|
}
|
||||||
patchForegroundStream(sessionId, buildingMessage)
|
beginStream(sessionId, buildingMessage)
|
||||||
const sendSource = options.input ? 'voice' : 'text'
|
const sendSource = options.input ? 'voice' : 'text'
|
||||||
const activeProvider = deps.getActiveProvider?.() ?? ''
|
const activeProvider = deps.getActiveProvider?.() ?? ''
|
||||||
// The user message is the durable start of a round, so its ID also serves
|
// The user message is the durable start of a round, so its ID also serves
|
||||||
@@ -604,7 +631,7 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
|
|||||||
text: speechOnly,
|
text: speechOnly,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
patchForegroundStream(sessionId, buildingMessage)
|
updateStream(sessionId, buildingMessage)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSpecial: async (special) => {
|
onSpecial: async (special) => {
|
||||||
@@ -624,7 +651,7 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
|
|||||||
speech: finalCategorization.speech,
|
speech: finalCategorization.speech,
|
||||||
reasoning: reasoningContentField || finalCategorization.reasoning,
|
reasoning: reasoningContentField || finalCategorization.reasoning,
|
||||||
}
|
}
|
||||||
patchForegroundStream(sessionId, buildingMessage)
|
updateStream(sessionId, buildingMessage)
|
||||||
},
|
},
|
||||||
// The parser keeps its own marker-safety tail. Emit each safe literal
|
// The parser keeps its own marker-safety tail. Emit each safe literal
|
||||||
// chunk so slow providers update the chat before they reach 24 characters.
|
// chunk so slow providers update the chat before they reach 24 characters.
|
||||||
@@ -638,13 +665,13 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
|
|||||||
return
|
return
|
||||||
if (ctx.data.type === 'tool-call') {
|
if (ctx.data.type === 'tool-call') {
|
||||||
buildingMessage.slices.push(ctx.data)
|
buildingMessage.slices.push(ctx.data)
|
||||||
patchForegroundStream(sessionId, buildingMessage)
|
updateStream(sessionId, buildingMessage)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx.data.type === 'tool-call-result') {
|
if (ctx.data.type === 'tool-call-result') {
|
||||||
buildingMessage.tool_results.push(ctx.data)
|
buildingMessage.tool_results.push(ctx.data)
|
||||||
patchForegroundStream(sessionId, buildingMessage)
|
updateStream(sessionId, buildingMessage)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -749,6 +776,9 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
|
|||||||
providerTranscript = structuredClone(currentTurnMessages)
|
providerTranscript = structuredClone(currentTurnMessages)
|
||||||
},
|
},
|
||||||
onUsage: (usage) => {
|
onUsage: (usage) => {
|
||||||
|
if (shouldAbort())
|
||||||
|
return
|
||||||
|
|
||||||
generationUsage = usage
|
generationUsage = usage
|
||||||
deps.onLlmGeneration?.({
|
deps.onLlmGeneration?.({
|
||||||
...correlation,
|
...correlation,
|
||||||
@@ -761,6 +791,9 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
onStreamEvent: async (event: StreamEvent) => {
|
onStreamEvent: async (event: StreamEvent) => {
|
||||||
|
if (shouldAbort())
|
||||||
|
return
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'tool-call':
|
case 'tool-call':
|
||||||
toolCallQueue.enqueue({
|
toolCallQueue.enqueue({
|
||||||
@@ -812,7 +845,7 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
|
|||||||
= Math.floor(nextReasoning.length / REASONING_UI_FLUSH_CHUNK_SIZE)
|
= Math.floor(nextReasoning.length / REASONING_UI_FLUSH_CHUNK_SIZE)
|
||||||
> Math.floor(reasoning.length / REASONING_UI_FLUSH_CHUNK_SIZE)
|
> Math.floor(reasoning.length / REASONING_UI_FLUSH_CHUNK_SIZE)
|
||||||
if (!reasoning || crossesBoundary)
|
if (!reasoning || crossesBoundary)
|
||||||
patchForegroundStream(sessionId, buildingMessage)
|
updateStream(sessionId, buildingMessage)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'finish':
|
case 'finish':
|
||||||
@@ -823,7 +856,16 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Session generation is the lifecycle correlation key. Re-check it
|
||||||
|
// after every awaited completion boundary so deleting a session while a
|
||||||
|
// plugin hook runs cannot leak later hooks or success analytics.
|
||||||
|
if (shouldAbort())
|
||||||
|
return
|
||||||
|
|
||||||
await parser.end()
|
await parser.end()
|
||||||
|
if (shouldAbort())
|
||||||
|
return
|
||||||
|
|
||||||
buildingMessage.providerTranscript = providerTranscript
|
buildingMessage.providerTranscript = providerTranscript
|
||||||
deps.onAssistantResponseRendered?.({
|
deps.onAssistantResponseRendered?.({
|
||||||
...correlation,
|
...correlation,
|
||||||
@@ -841,17 +883,29 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldAbort())
|
||||||
|
return
|
||||||
await hooks.emitStreamEndHooks(streamingMessageContext)
|
await hooks.emitStreamEndHooks(streamingMessageContext)
|
||||||
|
if (shouldAbort())
|
||||||
|
return
|
||||||
await hooks.emitAssistantResponseEndHooks(fullText, streamingMessageContext)
|
await hooks.emitAssistantResponseEndHooks(fullText, streamingMessageContext)
|
||||||
|
|
||||||
|
if (shouldAbort())
|
||||||
|
return
|
||||||
await hooks.emitAfterSendHooks(sendingMessage, streamingMessageContext)
|
await hooks.emitAfterSendHooks(sendingMessage, streamingMessageContext)
|
||||||
|
if (shouldAbort())
|
||||||
|
return
|
||||||
await hooks.emitAssistantMessageHooks({ ...buildingMessage }, fullText, streamingMessageContext)
|
await hooks.emitAssistantMessageHooks({ ...buildingMessage }, fullText, streamingMessageContext)
|
||||||
|
if (shouldAbort())
|
||||||
|
return
|
||||||
await hooks.emitChatTurnCompleteHooks({
|
await hooks.emitChatTurnCompleteHooks({
|
||||||
output: { ...buildingMessage },
|
output: { ...buildingMessage },
|
||||||
outputText: fullText,
|
outputText: fullText,
|
||||||
toolCalls: sessionMessagesForSend.filter(msg => msg.role === 'tool') as ToolMessage[],
|
toolCalls: sessionMessagesForSend.filter(msg => msg.role === 'tool') as ToolMessage[],
|
||||||
}, streamingMessageContext)
|
}, streamingMessageContext)
|
||||||
|
|
||||||
|
if (shouldAbort())
|
||||||
|
return
|
||||||
deps.onAssistantTurnReady?.({
|
deps.onAssistantTurnReady?.({
|
||||||
messageText: fullText,
|
messageText: fullText,
|
||||||
sessionMessages: sessionMessagesForSend,
|
sessionMessages: sessionMessagesForSend,
|
||||||
@@ -880,6 +934,9 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
|
if (isStaleGeneration())
|
||||||
|
return
|
||||||
|
|
||||||
console.error('Error sending message:', error)
|
console.error('Error sending message:', error)
|
||||||
deps.onMessageRoundFailed?.({
|
deps.onMessageRoundFailed?.({
|
||||||
...correlation,
|
...correlation,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics'
|
|||||||
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||||
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
||||||
import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store'
|
import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store'
|
||||||
|
import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge'
|
||||||
import { useDeferredMount } from '@proj-airi/ui'
|
import { useDeferredMount } from '@proj-airi/ui'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
@@ -17,18 +18,30 @@ import ChatContainer from '../Widgets/ChatContainer.vue'
|
|||||||
import { useChatToolCallRerun } from '../../composables/useChatToolCallRerun'
|
import { useChatToolCallRerun } from '../../composables/useChatToolCallRerun'
|
||||||
|
|
||||||
const { isReady } = useDeferredMount()
|
const { isReady } = useDeferredMount()
|
||||||
const { sending } = storeToRefs(useChatStore())
|
const { activeSendSessionId, activeStreamingMessage, sending } = storeToRefs(useChatStore())
|
||||||
const { messages } = storeToRefs(useChatSessionStore())
|
const { activeSessionId, messages } = storeToRefs(useChatSessionStore())
|
||||||
const { streamingMessage } = storeToRefs(useChatStreamStore())
|
const { streamingMessage } = storeToRefs(useChatStreamStore())
|
||||||
|
const { isReceivingRemoteStream } = storeToRefs(useContextBridgeStore())
|
||||||
|
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const historyMessages = computed(() => messages.value as unknown as ChatHistoryItem[])
|
const historyMessages = computed(() => messages.value as unknown as ChatHistoryItem[])
|
||||||
|
const isActiveSessionSending = computed(() => (
|
||||||
|
(sending.value && activeSendSessionId.value === activeSessionId.value)
|
||||||
|
|| isReceivingRemoteStream.value
|
||||||
|
))
|
||||||
|
const visibleStreamingMessage = computed(() => activeSendSessionId.value === activeSessionId.value
|
||||||
|
? activeStreamingMessage.value
|
||||||
|
: streamingMessage.value)
|
||||||
const { trackChatMessageDeleted } = useAnalytics()
|
const { trackChatMessageDeleted } = useAnalytics()
|
||||||
const { rerunToolCall } = useChatToolCallRerun()
|
const { rerunToolCall } = useChatToolCallRerun()
|
||||||
|
|
||||||
function handleDeleteMessage(index: number) {
|
async function handleDeleteMessage(index: number) {
|
||||||
const message = messages.value[index]
|
const message = messages.value[index]
|
||||||
messages.value = messages.value.filter((_, messageIndex) => messageIndex !== index)
|
await useChatSessionStore().deleteMessage({
|
||||||
|
sessionId: activeSessionId.value,
|
||||||
|
messageId: message?.id,
|
||||||
|
index,
|
||||||
|
})
|
||||||
trackChatMessageDeleted({
|
trackChatMessageDeleted({
|
||||||
source: 'history',
|
source: 'history',
|
||||||
message_role: message?.role ?? 'unknown',
|
message_role: message?.role ?? 'unknown',
|
||||||
@@ -51,8 +64,8 @@ function handleDeleteMessage(index: number) {
|
|||||||
<ChatHistory
|
<ChatHistory
|
||||||
v-if="isReady"
|
v-if="isReady"
|
||||||
:messages="historyMessages"
|
:messages="historyMessages"
|
||||||
:sending="sending"
|
:sending="isActiveSessionSending"
|
||||||
:streaming-message="streamingMessage"
|
:streaming-message="visibleStreamingMessage"
|
||||||
h-full
|
h-full
|
||||||
variant="desktop"
|
variant="desktop"
|
||||||
@delete-message="handleDeleteMessage($event.index)"
|
@delete-message="handleDeleteMessage($event.index)"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat'
|
import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat'
|
||||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
|
||||||
|
|
||||||
import { errorMessageFrom } from '@moeru/std'
|
import { errorMessageFrom } from '@moeru/std'
|
||||||
import { isStageTamagotchi } from '@proj-airi/stage-shared'
|
import { isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||||
@@ -14,9 +13,7 @@ import { useChatMaintenanceStore } from '@proj-airi/stage-ui/stores/chat/mainten
|
|||||||
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
||||||
import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store'
|
import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store'
|
||||||
import { useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d'
|
import { useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d'
|
||||||
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
|
import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/context-bridge'
|
||||||
import { useProviderConfigStore } from '@proj-airi/stage-ui/stores/providers/config'
|
|
||||||
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
|
|
||||||
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||||
import { BasicTextarea, useTheme } from '@proj-airi/ui'
|
import { BasicTextarea, useTheme } from '@proj-airi/ui'
|
||||||
import { useResizeObserver, useScreenSafeArea } from '@vueuse/core'
|
import { useResizeObserver, useScreenSafeArea } from '@vueuse/core'
|
||||||
@@ -39,16 +36,28 @@ const chatOrchestrator = useChatStore()
|
|||||||
const chatSession = useChatSessionStore()
|
const chatSession = useChatSessionStore()
|
||||||
const chatStream = useChatStreamStore()
|
const chatStream = useChatStreamStore()
|
||||||
const { cleanupMessages } = useChatMaintenanceStore()
|
const { cleanupMessages } = useChatMaintenanceStore()
|
||||||
const { messages } = storeToRefs(chatSession)
|
const { activeSessionId, messages } = storeToRefs(chatSession)
|
||||||
const { streamingMessage } = storeToRefs(chatStream)
|
const { streamingMessage } = storeToRefs(chatStream)
|
||||||
const { sending } = storeToRefs(chatOrchestrator)
|
const { activeSendSessionId, activeStreamingMessage, sending } = storeToRefs(chatOrchestrator)
|
||||||
|
const { isReceivingRemoteStream } = storeToRefs(useContextBridgeStore())
|
||||||
const historyMessages = computed(() => messages.value as unknown as ChatHistoryItem[])
|
const historyMessages = computed(() => messages.value as unknown as ChatHistoryItem[])
|
||||||
|
const isActiveSessionSending = computed(() => (
|
||||||
|
(sending.value && activeSendSessionId.value === activeSessionId.value)
|
||||||
|
|| isReceivingRemoteStream.value
|
||||||
|
))
|
||||||
|
const visibleStreamingMessage = computed(() => activeSendSessionId.value === activeSessionId.value
|
||||||
|
? activeStreamingMessage.value
|
||||||
|
: streamingMessage.value)
|
||||||
const { trackChatMessageDeleted, trackChatMessagesCleared } = useAnalytics()
|
const { trackChatMessageDeleted, trackChatMessagesCleared } = useAnalytics()
|
||||||
const { rerunToolCall } = useChatToolCallRerun()
|
const { rerunToolCall } = useChatToolCallRerun()
|
||||||
|
|
||||||
function handleDeleteMessage(index: number) {
|
async function handleDeleteMessage(index: number) {
|
||||||
const message = messages.value[index]
|
const message = messages.value[index]
|
||||||
messages.value = messages.value.filter((_, messageIndex) => messageIndex !== index)
|
await chatSession.deleteMessage({
|
||||||
|
sessionId: activeSessionId.value,
|
||||||
|
messageId: message?.id,
|
||||||
|
index,
|
||||||
|
})
|
||||||
trackChatMessageDeleted({
|
trackChatMessageDeleted({
|
||||||
source: 'history',
|
source: 'history',
|
||||||
message_role: message?.role ?? 'unknown',
|
message_role: message?.role ?? 'unknown',
|
||||||
@@ -70,17 +79,12 @@ const backgroundDialogOpen = ref(false)
|
|||||||
const sessionsDrawerOpen = ref(false)
|
const sessionsDrawerOpen = ref(false)
|
||||||
|
|
||||||
const screenSafeArea = useScreenSafeArea()
|
const screenSafeArea = useScreenSafeArea()
|
||||||
const providersStore = useProviderStore()
|
|
||||||
const providerStore = useProviderConfigStore()
|
|
||||||
const { activeProvider, activeModel } = storeToRefs(useConsciousnessStore())
|
|
||||||
|
|
||||||
useResizeObserver(document.documentElement, () => screenSafeArea.update())
|
useResizeObserver(document.documentElement, () => screenSafeArea.update())
|
||||||
const { themeColorsHueDynamic } = storeToRefs(useSettings())
|
const { themeColorsHueDynamic } = storeToRefs(useSettings())
|
||||||
const { viewControlsEnabled: l2dViewCtrlEnabled } = useL2dViewControl()
|
const { viewControlsEnabled: l2dViewCtrlEnabled } = useL2dViewControl()
|
||||||
const { viewControlsEnabled: threeViewCtrlEnabled } = useThreeViewControl()
|
const { viewControlsEnabled: threeViewCtrlEnabled } = useThreeViewControl()
|
||||||
const settingsAudioDevice = useSettingsAudioDevice()
|
const settingsAudioDevice = useSettingsAudioDevice()
|
||||||
const { enabled, stream } = storeToRefs(settingsAudioDevice)
|
const { enabled, stream } = storeToRefs(settingsAudioDevice)
|
||||||
const { ingest, onAfterMessageComposed } = chatOrchestrator
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { audioContext } = useAudioContext()
|
const { audioContext } = useAudioContext()
|
||||||
const { startAnalyzer, stopAnalyzer } = useAudioAnalyzer()
|
const { startAnalyzer, stopAnalyzer } = useAudioAnalyzer()
|
||||||
@@ -112,23 +116,24 @@ async function handleSend() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const textToSend = messageInput.value
|
const textToSend = messageInput.value
|
||||||
|
const targetSessionId = chatSession.activeSessionId
|
||||||
messageInput.value = ''
|
messageInput.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const providerConfig = providerStore.getProviderConfig(activeProvider.value)
|
await chatOrchestrator.send({
|
||||||
|
sessionId: targetSessionId,
|
||||||
await ingest(textToSend, {
|
text: textToSend,
|
||||||
chatProvider: await providersStore.getProviderInstance(activeProvider.value) as ChatProvider,
|
|
||||||
model: activeModel.value,
|
|
||||||
providerConfig,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
messageInput.value = textToSend
|
const errorMessage = errorMessageFrom(error) ?? String(error)
|
||||||
chatSession.appendSessionMessage(chatSession.activeSessionId, {
|
const wasCancelledForDeletedSession
|
||||||
role: 'error',
|
= errorMessage.includes('Chat session was reset before send could start')
|
||||||
content: errorMessageFrom(error) ?? 'Failed to send message',
|
|| errorMessage.includes('Chat session was removed before send completed')
|
||||||
})
|
if (!wasCancelledForDeletedSession && chatSession.activeSessionId === targetSessionId) {
|
||||||
|
const currentDraft = messageInput.value
|
||||||
|
messageInput.value = currentDraft ? `${textToSend}\n${currentDraft}` : textToSend
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,9 +163,6 @@ watch([enabled, stream], () => {
|
|||||||
setupAnalyzer()
|
setupAnalyzer()
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
onAfterMessageComposed(async () => {
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
teardownAnalyzer()
|
teardownAnalyzer()
|
||||||
})
|
})
|
||||||
@@ -179,8 +181,8 @@ onMounted(() => {
|
|||||||
v-if="!threeViewCtrlEnabled && !l2dViewCtrlEnabled"
|
v-if="!threeViewCtrlEnabled && !l2dViewCtrlEnabled"
|
||||||
variant="mobile"
|
variant="mobile"
|
||||||
:messages="historyMessages"
|
:messages="historyMessages"
|
||||||
:sending="sending"
|
:sending="isActiveSessionSending"
|
||||||
:streaming-message="streamingMessage"
|
:streaming-message="visibleStreamingMessage"
|
||||||
max-w="[calc(100%-3.5rem)]"
|
max-w="[calc(100%-3.5rem)]"
|
||||||
w-full self-start pb-3 pl-3
|
w-full self-start pb-3 pl-3
|
||||||
class="chat-history"
|
class="chat-history"
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
|
||||||
|
|
||||||
import { errorMessageFrom } from '@moeru/std'
|
import { errorMessageFrom } from '@moeru/std'
|
||||||
import { isStageTamagotchi } from '@proj-airi/stage-shared'
|
import { isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||||
import { HearingConfig } from '@proj-airi/stage-ui/components/scenarios/dialogs/audio-input/index'
|
import { HearingConfig } from '@proj-airi/stage-ui/components/scenarios/dialogs/audio-input/index'
|
||||||
@@ -8,9 +6,6 @@ import { useAudioAnalyzer } from '@proj-airi/stage-ui/composables'
|
|||||||
import { useAudioContext } from '@proj-airi/stage-ui/stores/audio'
|
import { useAudioContext } from '@proj-airi/stage-ui/stores/audio'
|
||||||
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||||
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-store'
|
||||||
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
|
|
||||||
import { useProviderConfigStore } from '@proj-airi/stage-ui/stores/providers/config'
|
|
||||||
import { useProviderStore } from '@proj-airi/stage-ui/stores/providers/provider'
|
|
||||||
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
import { useSettings, useSettingsAudioDevice } from '@proj-airi/stage-ui/stores/settings'
|
||||||
import { BasicTextarea } from '@proj-airi/ui'
|
import { BasicTextarea } from '@proj-airi/ui'
|
||||||
import { useLocalStorage } from '@vueuse/core'
|
import { useLocalStorage } from '@vueuse/core'
|
||||||
@@ -34,17 +29,12 @@ type SendMode = (typeof SEND_MODES)[number]
|
|||||||
const sendMode = useLocalStorage<SendMode>('ui/chat/settings/send-mode', 'enter')
|
const sendMode = useLocalStorage<SendMode>('ui/chat/settings/send-mode', 'enter')
|
||||||
const lastEnterTime = ref(0)
|
const lastEnterTime = ref(0)
|
||||||
|
|
||||||
const providersStore = useProviderStore()
|
|
||||||
|
|
||||||
const providerStore = useProviderConfigStore()
|
|
||||||
const { activeProvider, activeModel } = storeToRefs(useConsciousnessStore())
|
|
||||||
const { themeColorsHueDynamic } = storeToRefs(useSettings())
|
const { themeColorsHueDynamic } = storeToRefs(useSettings())
|
||||||
|
|
||||||
const { askPermission } = useSettingsAudioDevice()
|
const { askPermission } = useSettingsAudioDevice()
|
||||||
const { enabled, stream } = storeToRefs(useSettingsAudioDevice())
|
const { enabled, stream } = storeToRefs(useSettingsAudioDevice())
|
||||||
const chatOrchestrator = useChatStore()
|
const chatOrchestrator = useChatStore()
|
||||||
const chatSession = useChatSessionStore()
|
const chatSession = useChatSessionStore()
|
||||||
const { ingest, onAfterMessageComposed } = chatOrchestrator
|
|
||||||
const { audioContext } = useAudioContext()
|
const { audioContext } = useAudioContext()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const sendModeLabels = computed<Record<SendMode, string>>(() => ({
|
const sendModeLabels = computed<Record<SendMode, string>>(() => ({
|
||||||
@@ -68,24 +58,24 @@ async function handleSend() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const textToSend = messageInput.value
|
const textToSend = messageInput.value
|
||||||
|
const targetSessionId = chatSession.activeSessionId
|
||||||
messageInput.value = ''
|
messageInput.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const providerConfig = providerStore.getProviderConfig(activeProvider.value)
|
await chatOrchestrator.send({
|
||||||
|
sessionId: targetSessionId,
|
||||||
await ingest(textToSend, {
|
text: textToSend,
|
||||||
chatProvider: await providersStore.getProviderInstance(activeProvider.value) as ChatProvider,
|
|
||||||
model: activeModel.value,
|
|
||||||
providerConfig,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
// preserve any user input when failed to send the message
|
const errorMessage = errorMessageFrom(error) ?? String(error)
|
||||||
messageInput.value = [textToSend, messageInput.value.trim()].filter(Boolean).join(' ')
|
const wasCancelledForDeletedSession
|
||||||
chatSession.appendSessionMessage(chatSession.activeSessionId, {
|
= errorMessage.includes('Chat session was reset before send could start')
|
||||||
role: 'error',
|
|| errorMessage.includes('Chat session was removed before send completed')
|
||||||
content: errorMessageFrom(error) ?? 'Failed to send message',
|
if (!wasCancelledForDeletedSession && chatSession.activeSessionId === targetSessionId) {
|
||||||
})
|
const currentDraft = messageInput.value
|
||||||
|
messageInput.value = currentDraft ? `${textToSend}\n${currentDraft}` : textToSend
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,9 +125,6 @@ watch(hearingPopoverOpen, async (value) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onAfterMessageComposed(async () => {
|
|
||||||
})
|
|
||||||
|
|
||||||
const { startAnalyzer, stopAnalyzer } = useAudioAnalyzer()
|
const { startAnalyzer, stopAnalyzer } = useAudioAnalyzer()
|
||||||
let analyzerSource: MediaStreamAudioSourceNode | undefined
|
let analyzerSource: MediaStreamAudioSourceNode | undefined
|
||||||
|
|
||||||
|
|||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
import type { ChatSessionMeta } from '../../../../types/chat-session'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { render } from 'vitest-browser-vue'
|
||||||
|
import { defineComponent, ref } from 'vue'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import SessionsDialog from './sessions-dialog.vue'
|
||||||
|
|
||||||
|
function createTestI18n() {
|
||||||
|
return createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
messages: {
|
||||||
|
en: {
|
||||||
|
stage: {
|
||||||
|
chat: {
|
||||||
|
sessions: {
|
||||||
|
'title': 'Chats',
|
||||||
|
'new': 'New chat',
|
||||||
|
'empty': 'No chats',
|
||||||
|
'delete': 'Delete',
|
||||||
|
'cloud-badge': 'Cloud synced',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionMeta(sessionId: string, updatedAt: number): ChatSessionMeta {
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
characterId: 'default',
|
||||||
|
userId: 'local',
|
||||||
|
createdAt: updatedAt,
|
||||||
|
updatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHarness() {
|
||||||
|
return defineComponent({
|
||||||
|
name: 'SessionsDialogHarness',
|
||||||
|
components: { SessionsDialog },
|
||||||
|
setup() {
|
||||||
|
const created = ref(0)
|
||||||
|
const selected = ref('none')
|
||||||
|
const deleted = ref('none')
|
||||||
|
|
||||||
|
return {
|
||||||
|
created,
|
||||||
|
deleted,
|
||||||
|
selected,
|
||||||
|
rows: [
|
||||||
|
{ meta: sessionMeta('session-one', 2), preview: 'First chat', isActive: true, updatedAtLabel: 'now' },
|
||||||
|
{ meta: sessionMeta('session-two', 1), preview: 'Second chat', isActive: false, updatedAtLabel: 'yesterday' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<SessionsDialog
|
||||||
|
:open="true"
|
||||||
|
:rows="rows"
|
||||||
|
:is-desktop="false"
|
||||||
|
:is-creating-session="false"
|
||||||
|
mobile-padding-bottom="24px"
|
||||||
|
@new-session="created += 1"
|
||||||
|
@select-session="selected = $event"
|
||||||
|
@delete-session="deleted = $event"
|
||||||
|
/>
|
||||||
|
<output aria-label="created-session-count">{{ created }}</output>
|
||||||
|
<output aria-label="selected-session-id">{{ selected }}</output>
|
||||||
|
<output aria-label="deleted-session-id">{{ deleted }}</output>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('sessions dialog actions', () => {
|
||||||
|
// https://github.com/moeru-ai/airi/issues/2085
|
||||||
|
it('keeps add, switch, and delete actions independent for Issue #2085', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// Vaul handled every pointer release on DrawerContent, including releases
|
||||||
|
// from its action buttons, and unmounted the sheet before `click` ran.
|
||||||
|
// The replacement uses a Reka dialog surface whose buttons emit one action
|
||||||
|
// each without a competing gesture-release lifecycle.
|
||||||
|
const screen = await render(createHarness(), {
|
||||||
|
global: {
|
||||||
|
plugins: [createTestI18n()],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await screen.getByRole('button', { name: 'New chat' }).click()
|
||||||
|
await expect.element(screen.getByLabelText('created-session-count')).toHaveTextContent('1')
|
||||||
|
|
||||||
|
await screen.getByRole('button', { name: 'Delete: Second chat' }).click()
|
||||||
|
await expect.element(screen.getByLabelText('deleted-session-id')).toHaveTextContent('session-two')
|
||||||
|
await expect.element(screen.getByLabelText('selected-session-id')).toHaveTextContent('none')
|
||||||
|
|
||||||
|
await screen.getByRole('button', { name: 'First chat now' }).click()
|
||||||
|
await expect.element(screen.getByLabelText('selected-session-id')).toHaveTextContent('session-one')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ChatSessionMeta } from '../../../../types/chat-session'
|
||||||
|
|
||||||
|
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
interface SessionRow {
|
||||||
|
meta: ChatSessionMeta
|
||||||
|
preview: string
|
||||||
|
isActive: boolean
|
||||||
|
updatedAtLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
rows: SessionRow[]
|
||||||
|
isDesktop: boolean
|
||||||
|
isCreatingSession: boolean
|
||||||
|
mobilePaddingBottom: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'deleteSession': [sessionId: string]
|
||||||
|
'newSession': []
|
||||||
|
'selectSession': [sessionId: string]
|
||||||
|
'update:open': [open: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogRoot :open="open" @update:open="value => emit('update:open', value)">
|
||||||
|
<slot name="trigger" />
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay
|
||||||
|
:class="[
|
||||||
|
'fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm',
|
||||||
|
'data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn',
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<DialogContent
|
||||||
|
:aria-describedby="undefined"
|
||||||
|
:class="[
|
||||||
|
'fixed z-[9999] flex flex-col overflow-hidden bg-white/95 shadow-xl outline-none backdrop-blur-md dark:bg-neutral-900/95',
|
||||||
|
isDesktop
|
||||||
|
? 'left-1/2 top-1/2 max-h-[80dvh] max-w-md w-[92dvw] rounded-2xl -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow'
|
||||||
|
: 'bottom-0 left-0 right-0 max-h-[85dvh] rounded-t-[32px]',
|
||||||
|
]"
|
||||||
|
:style="isDesktop ? undefined : { paddingBottom: mobilePaddingBottom }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="!isDesktop"
|
||||||
|
:class="['mx-auto mt-3 h-1.5 w-12 shrink-0 rounded-full bg-neutral-400 dark:bg-neutral-600']"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div :class="['flex min-h-0 flex-1 flex-col']">
|
||||||
|
<div :class="['flex items-center justify-between px-5 pt-5 pb-3']">
|
||||||
|
<DialogTitle :class="['text-base font-medium text-neutral-700 dark:text-neutral-200']">
|
||||||
|
{{ t('stage.chat.sessions.title') }}
|
||||||
|
</DialogTitle>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
'rounded-lg px-3 py-1.5 text-xs font-medium',
|
||||||
|
'bg-primary-100/60 text-primary-700 dark:bg-primary-900/40 dark:text-primary-200',
|
||||||
|
'hover:bg-primary-200/70 dark:hover:bg-primary-800/50',
|
||||||
|
'transition-colors',
|
||||||
|
]"
|
||||||
|
:disabled="isCreatingSession"
|
||||||
|
@click="emit('newSession')"
|
||||||
|
>
|
||||||
|
{{ t('stage.chat.sessions.new') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div :class="['flex-1 overflow-y-auto px-2 pb-4']">
|
||||||
|
<div v-if="rows.length === 0" :class="['p-6 text-center text-sm text-neutral-500 dark:text-neutral-400']">
|
||||||
|
{{ t('stage.chat.sessions.empty') }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="row in rows"
|
||||||
|
:key="row.meta.sessionId"
|
||||||
|
:class="[
|
||||||
|
'group relative mb-1 w-full rounded-xl transition-colors',
|
||||||
|
row.isActive
|
||||||
|
? 'bg-primary-100/70 dark:bg-primary-900/40'
|
||||||
|
: 'hover:bg-neutral-100/80 dark:hover:bg-neutral-800/60',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="['w-full flex flex-col gap-1 px-3 py-3 text-left outline-none']"
|
||||||
|
@click="emit('selectSession', row.meta.sessionId)"
|
||||||
|
>
|
||||||
|
<div :class="['flex items-center gap-2 text-sm font-medium text-neutral-700 dark:text-neutral-200']">
|
||||||
|
<span :class="['flex-1 truncate']">{{ row.preview }}</span>
|
||||||
|
<span
|
||||||
|
v-if="row.meta.cloudChatId"
|
||||||
|
:class="['shrink-0 rounded px-1.5 py-0.5 text-[10px] uppercase tracking-wide', 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300']"
|
||||||
|
:title="t('stage.chat.sessions.cloud-badge')"
|
||||||
|
>
|
||||||
|
cloud
|
||||||
|
</span>
|
||||||
|
<span :class="['w-7']" />
|
||||||
|
</div>
|
||||||
|
<div :class="['text-[11px] text-neutral-500 dark:text-neutral-400']">
|
||||||
|
{{ row.updatedAtLabel }}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
'absolute right-2 top-2 z-10 h-7 w-7 flex items-center justify-center rounded-md',
|
||||||
|
'opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100',
|
||||||
|
'text-neutral-400 hover:bg-red-500/10 hover:text-red-500',
|
||||||
|
'transition-opacity duration-150',
|
||||||
|
]"
|
||||||
|
:aria-label="`${t('stage.chat.sessions.delete')}: ${row.preview}`"
|
||||||
|
:title="t('stage.chat.sessions.delete')"
|
||||||
|
@click.stop="emit('deleteSession', row.meta.sessionId)"
|
||||||
|
>
|
||||||
|
<div class="i-solar:trash-bin-trash-bold-duotone h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogPortal>
|
||||||
|
</DialogRoot>
|
||||||
|
</template>
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
import type { ChatSessionMeta } from '../../../../types/chat-session'
|
||||||
|
|
||||||
|
import { PiniaColada } from '@pinia/colada'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { render } from 'vitest-browser-vue'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import SessionsDrawer from './sessions-drawer.vue'
|
||||||
|
|
||||||
|
import { useChatStore } from '../../../../stores/chat'
|
||||||
|
import { useChatSessionStore } from '../../../../stores/chat/session-store'
|
||||||
|
|
||||||
|
function createTestI18n() {
|
||||||
|
return createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
missingWarn: false,
|
||||||
|
fallbackWarn: false,
|
||||||
|
messages: {
|
||||||
|
en: {
|
||||||
|
stage: {
|
||||||
|
chat: {
|
||||||
|
sessions: {
|
||||||
|
'title': 'Chats',
|
||||||
|
'new': 'New chat',
|
||||||
|
'empty': 'No chats',
|
||||||
|
'delete': 'Delete',
|
||||||
|
'cloud-badge': 'Cloud synced',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionMeta(sessionId: string, title: string, updatedAt: number): ChatSessionMeta {
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
title,
|
||||||
|
characterId: 'default',
|
||||||
|
userId: 'local',
|
||||||
|
createdAt: updatedAt,
|
||||||
|
updatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSessionsPinia() {
|
||||||
|
const pinia = createPinia()
|
||||||
|
pinia.state.value = {
|
||||||
|
'chat-session-selection': {
|
||||||
|
activeSessionId: 'session-b',
|
||||||
|
},
|
||||||
|
'chat-session': {
|
||||||
|
sessionMetas: {
|
||||||
|
'session-a': sessionMeta('session-a', 'Chat A', 1),
|
||||||
|
'session-b': sessionMeta('session-b', 'Chat B', 3),
|
||||||
|
'session-c': sessionMeta('session-c', 'Chat C', 2),
|
||||||
|
},
|
||||||
|
sessionMessages: {
|
||||||
|
'session-a': [],
|
||||||
|
'session-b': [],
|
||||||
|
'session-c': [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return pinia
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('sessions drawer orchestration', () => {
|
||||||
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743073795
|
||||||
|
it('preserves a newer selection while active-session deletion is pending for Issue #2085', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// Deletion captured that B was active, then unconditionally selected its
|
||||||
|
// fallback after the asynchronous leader action completed. A user choice
|
||||||
|
// of C made during that await was therefore overwritten by stale work.
|
||||||
|
const pinia = createSessionsPinia()
|
||||||
|
|
||||||
|
const screen = await render(SessionsDrawer, {
|
||||||
|
props: { modelValue: false },
|
||||||
|
global: {
|
||||||
|
plugins: [pinia, PiniaColada, createTestI18n()],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const chatSession = useChatSessionStore(pinia)
|
||||||
|
const chat = useChatStore(pinia)
|
||||||
|
let resolveDelete: (() => void) | undefined
|
||||||
|
vi.spyOn(chat, 'deleteSession').mockImplementationOnce(() => new Promise((resolve) => {
|
||||||
|
resolveDelete = resolve
|
||||||
|
}))
|
||||||
|
vi.spyOn(chatSession, 'loadSession').mockResolvedValue(true)
|
||||||
|
vi.spyOn(chatSession, 'setActiveSession').mockImplementation(async (sessionId) => {
|
||||||
|
chatSession.activeSessionId = sessionId
|
||||||
|
})
|
||||||
|
await screen.rerender({ modelValue: true })
|
||||||
|
|
||||||
|
await screen.getByRole('button', { name: 'Delete: Chat B' }).click()
|
||||||
|
await vi.waitFor(() => expect(chat.deleteSession).toHaveBeenCalledWith('session-b'))
|
||||||
|
|
||||||
|
await screen.getByRole('button', { name: /^Chat C / }).click()
|
||||||
|
expect(chatSession.activeSessionId).toBe('session-c')
|
||||||
|
|
||||||
|
resolveDelete?.()
|
||||||
|
await vi.waitFor(() => expect(chatSession.setActiveSession).toHaveBeenCalledTimes(1))
|
||||||
|
|
||||||
|
expect(chatSession.setActiveSession).toHaveBeenLastCalledWith('session-c')
|
||||||
|
expect(chatSession.activeSessionId).toBe('session-c')
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743221030
|
||||||
|
it('preserves a newer selection while session creation is pending for Issue #2085', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// New-session creation awaited leader persistence, then unconditionally
|
||||||
|
// selected its result. Session rows stay enabled during that wait, so a
|
||||||
|
// newer row selection was overwritten when creation eventually resolved.
|
||||||
|
const pinia = createSessionsPinia()
|
||||||
|
const screen = await render(SessionsDrawer, {
|
||||||
|
props: { modelValue: false },
|
||||||
|
global: {
|
||||||
|
plugins: [pinia, PiniaColada, createTestI18n()],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const chatSession = useChatSessionStore(pinia)
|
||||||
|
let resolveCreate: ((sessionId: string) => void) | undefined
|
||||||
|
vi.spyOn(chatSession, 'createSession').mockImplementationOnce(() => new Promise((resolve) => {
|
||||||
|
resolveCreate = resolve
|
||||||
|
}))
|
||||||
|
vi.spyOn(chatSession, 'loadSession').mockResolvedValue(true)
|
||||||
|
vi.spyOn(chatSession, 'setActiveSession').mockImplementation(async (sessionId) => {
|
||||||
|
chatSession.activeSessionId = sessionId
|
||||||
|
})
|
||||||
|
await screen.rerender({ modelValue: true })
|
||||||
|
|
||||||
|
await screen.getByRole('button', { name: 'New chat' }).click()
|
||||||
|
await vi.waitFor(() => expect(chatSession.createSession).toHaveBeenCalledWith('default', { setActive: false }))
|
||||||
|
|
||||||
|
await screen.getByRole('button', { name: /^Chat C / }).click()
|
||||||
|
expect(chatSession.activeSessionId).toBe('session-c')
|
||||||
|
|
||||||
|
resolveCreate?.('session-new')
|
||||||
|
await vi.waitFor(() => expect(chatSession.setActiveSession).toHaveBeenCalledTimes(1))
|
||||||
|
|
||||||
|
expect(chatSession.setActiveSession).toHaveBeenLastCalledWith('session-c')
|
||||||
|
expect(chatSession.activeSessionId).toBe('session-c')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,39 +3,20 @@ import type { ChatSessionMeta } from '../../../../types/chat-session'
|
|||||||
|
|
||||||
import { useResizeObserver, useScreenSafeArea } from '@vueuse/core'
|
import { useResizeObserver, useScreenSafeArea } from '@vueuse/core'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
|
|
||||||
import { DrawerContent, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTitle } from 'vaul-vue'
|
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import SessionsDialog from './sessions-dialog.vue'
|
||||||
|
|
||||||
import { useAnalytics } from '../../../../composables/use-analytics'
|
import { useAnalytics } from '../../../../composables/use-analytics'
|
||||||
import { useBreakpoints } from '../../../../composables/use-breakpoints'
|
import { useBreakpoints } from '../../../../composables/use-breakpoints'
|
||||||
import { extractMessageText } from '../../../../libs/chat-sync'
|
import { extractMessageText } from '../../../../libs/chat-sync'
|
||||||
import { useAuthStore } from '../../../../stores/auth'
|
import { useAuthStore } from '../../../../stores/auth'
|
||||||
|
import { useChatStore } from '../../../../stores/chat'
|
||||||
import { useChatSessionStore } from '../../../../stores/chat/session-store'
|
import { useChatSessionStore } from '../../../../stores/chat/session-store'
|
||||||
import { useAiriCardStore } from '../../../../stores/modules/airi-card'
|
import { useAiriCardStore } from '../../../../stores/modules/airi-card'
|
||||||
import { useConsciousnessStore } from '../../../../stores/modules/consciousness'
|
import { useConsciousnessStore } from '../../../../stores/modules/consciousness'
|
||||||
|
|
||||||
/**
|
|
||||||
* Bottom-sheet (mobile) / centered-modal (desktop) UI surface that lists every
|
|
||||||
* chat session belonging to the current user, lets the user switch between
|
|
||||||
* them, and start a fresh session for the active character.
|
|
||||||
*
|
|
||||||
* Use when:
|
|
||||||
* - The user is on a stage page and wants to browse / switch conversations.
|
|
||||||
* Mounted once near the global ChatArea so any input bar can flip the
|
|
||||||
* `v-model` open.
|
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - `useChatSessionStore` is initialized — `sessionMetas` and `activeSessionId`
|
|
||||||
* drive the list, and switching calls `setActiveSession` / `createSession`.
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - A scrollable list. List items render the session title (or first user
|
|
||||||
* message preview as a fallback), a cloud-sync badge, and a relative
|
|
||||||
* updatedAt timestamp.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const showDialog = defineModel({ type: Boolean, default: false, required: false })
|
const showDialog = defineModel({ type: Boolean, default: false, required: false })
|
||||||
|
|
||||||
const { isDesktop } = useBreakpoints()
|
const { isDesktop } = useBreakpoints()
|
||||||
@@ -43,18 +24,15 @@ const screenSafeArea = useScreenSafeArea()
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const chatSession = useChatSessionStore()
|
const chatSession = useChatSessionStore()
|
||||||
|
const chat = useChatStore()
|
||||||
const { sessionMetas, sessionMessages, activeSessionId } = storeToRefs(chatSession)
|
const { sessionMetas, sessionMessages, activeSessionId } = storeToRefs(chatSession)
|
||||||
const { activeCardId } = storeToRefs(useAiriCardStore())
|
const { activeCardId } = storeToRefs(useAiriCardStore())
|
||||||
const { userId } = storeToRefs(useAuthStore())
|
const { userId } = storeToRefs(useAuthStore())
|
||||||
const { activeModel } = storeToRefs(useConsciousnessStore())
|
const { activeModel } = storeToRefs(useConsciousnessStore())
|
||||||
const { trackChatSessionSelected, trackChatSessionStarted } = useAnalytics()
|
const { trackChatSessionSelected, trackChatSessionStarted } = useAnalytics()
|
||||||
|
|
||||||
// Re-entry guard for the "new session" button. Without this, a rapid
|
// Creating includes persistence and cloud reconciliation, so prevent a
|
||||||
// double-click would call `createSession` twice (creating two orphan
|
// second click from creating an orphan session while the first is pending.
|
||||||
// sessions) and emit duplicate `chat_session_started` analytics events.
|
|
||||||
// The async `createSession` includes IndexedDB writes + a cloud reconcile
|
|
||||||
// kick-off, so even a single click can stay in flight long enough for a
|
|
||||||
// second click to slip through.
|
|
||||||
const isCreatingSession = ref(false)
|
const isCreatingSession = ref(false)
|
||||||
|
|
||||||
useResizeObserver(document.documentElement, () => screenSafeArea.update())
|
useResizeObserver(document.documentElement, () => screenSafeArea.update())
|
||||||
@@ -67,30 +45,18 @@ interface SessionRow {
|
|||||||
updatedAtLabel: string
|
updatedAtLabel: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Keep another account's sessions hidden while an account swap rehydrates.
|
||||||
* Sessions visible in the drawer. Filters by the currently effective user
|
|
||||||
* (`userId.value || 'local'`) so:
|
|
||||||
* - Anonymous users see their local-only sessions (previously hidden by a
|
|
||||||
* blanket `userId !== 'local'` filter).
|
|
||||||
* - After an account swap, the previously signed-in user's sessions stay
|
|
||||||
* hidden until ensureActiveSessionForCharacter rehydrates the new tenant
|
|
||||||
* (the session-store also clears in-memory state on user change as a
|
|
||||||
* defense in depth).
|
|
||||||
*/
|
|
||||||
const ownedSessions = computed(() => {
|
const ownedSessions = computed(() => {
|
||||||
const effectiveUserId = userId.value || 'local'
|
const effectiveUserId = userId.value || 'local'
|
||||||
return Object.values(sessionMetas.value).filter(meta => meta.userId === effectiveUserId)
|
return Object.values(sessionMetas.value).filter(meta => meta.userId === effectiveUserId)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pull a 1-line preview from the first non-system message; falls back to the
|
* Normalizes a session into its one-line drawer preview.
|
||||||
* stored title or a generic placeholder when nothing readable is available.
|
|
||||||
*
|
*
|
||||||
* Before:
|
* @example
|
||||||
* - messages: [system, { role: 'user', content: 'Tell me about the moon today' }, ...]
|
* previewFor({ title: 'Moon notes', ...meta })
|
||||||
*
|
* // => 'Moon notes'
|
||||||
* After:
|
|
||||||
* - "Tell me about the moon today"
|
|
||||||
*/
|
*/
|
||||||
function previewFor(meta: ChatSessionMeta): string {
|
function previewFor(meta: ChatSessionMeta): string {
|
||||||
if (meta.title)
|
if (meta.title)
|
||||||
@@ -118,13 +84,11 @@ const RELATIVE_UNITS: Array<[Intl.RelativeTimeFormatUnit, number]> = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format an epoch ms timestamp as a coarse relative label like "3 minutes ago".
|
* Normalizes an epoch timestamp into a coarse relative label.
|
||||||
*
|
*
|
||||||
* Before:
|
* @example
|
||||||
* - Date.now() - 5 * 60 * 1000
|
* formatUpdatedAt(Date.now() - 5 * 60 * 1000)
|
||||||
*
|
* // => '5 minutes ago'
|
||||||
* After:
|
|
||||||
* - "5 minutes ago"
|
|
||||||
*/
|
*/
|
||||||
function formatUpdatedAt(ts: number): string {
|
function formatUpdatedAt(ts: number): string {
|
||||||
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||||
@@ -147,12 +111,15 @@ const rows = computed<SessionRow[]>(() => {
|
|||||||
isActive: meta.sessionId === activeSessionId.value,
|
isActive: meta.sessionId === activeSessionId.value,
|
||||||
updatedAtLabel: formatUpdatedAt(meta.updatedAt),
|
updatedAtLabel: formatUpdatedAt(meta.updatedAt),
|
||||||
}))
|
}))
|
||||||
// Most-recent first; the active session usually ends up at the top after a
|
|
||||||
// fresh send because `persistSession` bumps `updatedAt`.
|
|
||||||
list.sort((a, b) => b.meta.updatedAt - a.meta.updatedAt)
|
list.sort((a, b) => b.meta.updatedAt - a.meta.updatedAt)
|
||||||
return list
|
return list
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const mobilePaddingBottom = computed(() => {
|
||||||
|
const safeAreaBottom = Number.parseFloat(screenSafeArea.bottom.value.replace('px', ''))
|
||||||
|
return `${Math.max(safeAreaBottom, 24)}px`
|
||||||
|
})
|
||||||
|
|
||||||
async function selectSession(sessionId: string) {
|
async function selectSession(sessionId: string) {
|
||||||
const selectedRow = rows.value.find(row => row.meta.sessionId === sessionId)
|
const selectedRow = rows.value.find(row => row.meta.sessionId === sessionId)
|
||||||
if (sessionId !== activeSessionId.value && selectedRow) {
|
if (sessionId !== activeSessionId.value && selectedRow) {
|
||||||
@@ -162,7 +129,7 @@ async function selectSession(sessionId: string) {
|
|||||||
cloud_synced: !!selectedRow.meta.cloudChatId,
|
cloud_synced: !!selectedRow.meta.cloudChatId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
chatSession.setActiveSession(sessionId)
|
await chatSession.setActiveSession(sessionId)
|
||||||
showDialog.value = false
|
showDialog.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,12 +139,16 @@ async function startNewSession() {
|
|||||||
isCreatingSession.value = true
|
isCreatingSession.value = true
|
||||||
try {
|
try {
|
||||||
const characterId = activeCardId.value || 'default'
|
const characterId = activeCardId.value || 'default'
|
||||||
await chatSession.createSession(characterId, { setActive: true })
|
const selectionBeforeCreation = activeSessionId.value
|
||||||
// PostHog retention denominator. We pick this call site (UI new-session
|
// Creation runs in the synchronized leader, while navigation belongs to
|
||||||
// button) rather than `createSession` in the store because the store also
|
// this window. Activating inside createSession would navigate the leader.
|
||||||
// creates sessions for cloud-reconcile / fork / restore flows that aren't
|
const sessionId = await chatSession.createSession(characterId, { setActive: false })
|
||||||
// user-initiated. Model id is informational; sessionIndex is omitted
|
// Rows remain interactive while creation is persisted in the leader. Do
|
||||||
// (PostHog can compute it from per-user event ordering).
|
// not let that stale continuation replace a newer local user selection.
|
||||||
|
if (activeSessionId.value === selectionBeforeCreation)
|
||||||
|
await chatSession.setActiveSession(sessionId)
|
||||||
|
// Store-created sessions also include restore and fork flows; only this
|
||||||
|
// user action belongs in the retention denominator.
|
||||||
trackChatSessionStarted(activeModel.value || 'unknown')
|
trackChatSessionStarted(activeModel.value || 'unknown')
|
||||||
showDialog.value = false
|
showDialog.value = false
|
||||||
}
|
}
|
||||||
@@ -186,13 +157,6 @@ async function startNewSession() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteRow(event: Event, sessionId: string) {
|
|
||||||
// Stop the parent button's click — otherwise we'd switch into the session
|
|
||||||
// we are about to remove and immediately need a fallback.
|
|
||||||
event.stopPropagation()
|
|
||||||
await chatSession.deleteSession(sessionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-open generation counter. The batch loadSession loop checks this before
|
// Per-open generation counter. The batch loadSession loop checks this before
|
||||||
// each batch so closing the drawer mid-load aborts cleanly instead of
|
// each batch so closing the drawer mid-load aborts cleanly instead of
|
||||||
// continuing to hydrate sessions the user has navigated away from. Without
|
// continuing to hydrate sessions the user has navigated away from. Without
|
||||||
@@ -200,17 +164,11 @@ async function deleteRow(event: Event, sessionId: string) {
|
|||||||
// re-added to `loadedSessions` as a phantom entry.
|
// re-added to `loadedSessions` as a phantom entry.
|
||||||
let openGeneration = 0
|
let openGeneration = 0
|
||||||
|
|
||||||
// Re-render relative timestamps + hydrate non-active session messages when
|
|
||||||
// the drawer opens so each row can show a real preview instead of the
|
|
||||||
// fallback. `loadSession` is idempotent (`loadedSessions` set), so reopening
|
|
||||||
// the drawer is cheap.
|
|
||||||
watch(showDialog, async (open) => {
|
watch(showDialog, async (open) => {
|
||||||
if (!open)
|
if (!open)
|
||||||
return
|
return
|
||||||
openGeneration += 1
|
openGeneration += 1
|
||||||
const myGeneration = openGeneration
|
const myGeneration = openGeneration
|
||||||
// Touch `rows` first so reactive labels reflect a fresh `Date.now()`.
|
|
||||||
void rows.value
|
|
||||||
const knownSessionIds = ownedSessions.value.map(meta => meta.sessionId)
|
const knownSessionIds = ownedSessions.value.map(meta => meta.sessionId)
|
||||||
// Bounded concurrency keeps a long history list from spawning a hundred
|
// Bounded concurrency keeps a long history list from spawning a hundred
|
||||||
// simultaneous IndexedDB transactions; 4 in flight is plenty for a list
|
// simultaneous IndexedDB transactions; 4 in flight is plenty for a list
|
||||||
@@ -225,172 +183,18 @@ watch(showDialog, async (open) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DialogRoot v-if="isDesktop" :open="showDialog" @update:open="value => showDialog = value">
|
<SessionsDialog
|
||||||
<slot name="trigger" />
|
v-model:open="showDialog"
|
||||||
<DialogPortal>
|
:rows="rows"
|
||||||
<DialogOverlay
|
:is-desktop="isDesktop"
|
||||||
:class="[
|
:is-creating-session="isCreatingSession"
|
||||||
'fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm',
|
:mobile-padding-bottom="mobilePaddingBottom"
|
||||||
'data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn',
|
@new-session="startNewSession"
|
||||||
]"
|
@select-session="selectSession"
|
||||||
/>
|
@delete-session="chat.deleteSession"
|
||||||
<DialogContent
|
>
|
||||||
:class="[
|
<template #trigger>
|
||||||
'fixed left-1/2 top-1/2 z-[9999] max-h-[80dvh] max-w-md w-[92dvw] transform overflow-hidden rounded-2xl bg-white/95 shadow-xl outline-none backdrop-blur-md scrollbar-none -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow dark:bg-neutral-900/90',
|
<slot name="trigger" />
|
||||||
]"
|
</template>
|
||||||
>
|
</SessionsDialog>
|
||||||
<div :class="['flex flex-col h-full max-h-[80dvh]']">
|
|
||||||
<div :class="['flex items-center justify-between px-5 pt-5 pb-3']">
|
|
||||||
<DialogTitle :class="['text-base font-medium text-neutral-700 dark:text-neutral-200']">
|
|
||||||
{{ t('stage.chat.sessions.title') }}
|
|
||||||
</DialogTitle>
|
|
||||||
<button
|
|
||||||
:class="[
|
|
||||||
'rounded-lg px-3 py-1.5 text-xs font-medium',
|
|
||||||
'bg-primary-100/60 text-primary-700 dark:bg-primary-900/40 dark:text-primary-200',
|
|
||||||
'hover:bg-primary-200/70 dark:hover:bg-primary-800/50',
|
|
||||||
'transition-colors',
|
|
||||||
]"
|
|
||||||
:disabled="isCreatingSession"
|
|
||||||
@click="startNewSession"
|
|
||||||
>
|
|
||||||
{{ t('stage.chat.sessions.new') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div :class="['flex-1 overflow-y-auto px-2 pb-4']">
|
|
||||||
<div v-if="rows.length === 0" :class="['p-6 text-center text-sm text-neutral-500 dark:text-neutral-400']">
|
|
||||||
{{ t('stage.chat.sessions.empty') }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="row in rows"
|
|
||||||
:key="row.meta.sessionId"
|
|
||||||
:class="[
|
|
||||||
'group relative w-full rounded-xl mb-1',
|
|
||||||
'transition-colors',
|
|
||||||
row.isActive
|
|
||||||
? 'bg-primary-100/70 dark:bg-primary-900/40'
|
|
||||||
: 'hover:bg-neutral-100/80 dark:hover:bg-neutral-800/60',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
:class="['w-full text-left px-3 py-2.5 outline-none flex flex-col gap-1']"
|
|
||||||
@click="selectSession(row.meta.sessionId)"
|
|
||||||
>
|
|
||||||
<div :class="['flex items-center gap-2 text-sm font-medium text-neutral-700 dark:text-neutral-200']">
|
|
||||||
<span :class="['truncate flex-1']">{{ row.preview }}</span>
|
|
||||||
<span
|
|
||||||
v-if="row.meta.cloudChatId"
|
|
||||||
:class="['shrink-0 text-[10px] uppercase tracking-wide rounded px-1.5 py-0.5', 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300']"
|
|
||||||
:title="t('stage.chat.sessions.cloud-badge')"
|
|
||||||
>
|
|
||||||
cloud
|
|
||||||
</span>
|
|
||||||
<!-- placeholder for trash icon to reserve hit space -->
|
|
||||||
<span :class="['w-7']" />
|
|
||||||
</div>
|
|
||||||
<div :class="['text-[11px] text-neutral-500 dark:text-neutral-400']">
|
|
||||||
{{ row.updatedAtLabel }}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="[
|
|
||||||
'absolute right-2 top-2 h-7 w-7 flex items-center justify-center rounded-md',
|
|
||||||
'opacity-0 group-hover:opacity-100 focus:opacity-100',
|
|
||||||
'text-neutral-400 hover:text-red-500 hover:bg-red-500/10',
|
|
||||||
'transition-opacity duration-150',
|
|
||||||
]"
|
|
||||||
:title="t('stage.chat.sessions.delete')"
|
|
||||||
@click="deleteRow($event, row.meta.sessionId)"
|
|
||||||
>
|
|
||||||
<div class="i-solar:trash-bin-trash-bold-duotone h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</DialogPortal>
|
|
||||||
</DialogRoot>
|
|
||||||
<DrawerRoot v-else :open="showDialog" should-scale-background @update:open="value => showDialog = value">
|
|
||||||
<DrawerPortal>
|
|
||||||
<DrawerOverlay :class="['fixed inset-0']" />
|
|
||||||
<DrawerContent
|
|
||||||
:class="[
|
|
||||||
'fixed bottom-0 left-0 right-0 z-1000',
|
|
||||||
'mt-20 px-2 pt-3',
|
|
||||||
'flex flex-col',
|
|
||||||
'h-full max-h-[85%]',
|
|
||||||
'rounded-t-[32px] outline-none backdrop-blur-md',
|
|
||||||
'bg-neutral-50/95 dark:bg-neutral-900/95',
|
|
||||||
]"
|
|
||||||
:style="{ paddingBottom: `${Math.max(Number.parseFloat(screenSafeArea.bottom.value.replace('px', '')), 24)}px` }"
|
|
||||||
>
|
|
||||||
<DrawerHandle :class="['[div&]:bg-neutral-400 [div&]:dark:bg-neutral-600']" />
|
|
||||||
<div :class="['flex items-center justify-between px-4 pt-3 pb-2']">
|
|
||||||
<DrawerTitle :class="['text-base font-medium text-neutral-700 dark:text-neutral-200']">
|
|
||||||
{{ t('stage.chat.sessions.title') }}
|
|
||||||
</DrawerTitle>
|
|
||||||
<button
|
|
||||||
:class="[
|
|
||||||
'rounded-lg px-3 py-1.5 text-xs font-medium',
|
|
||||||
'bg-primary-100/60 text-primary-700 dark:bg-primary-900/40 dark:text-primary-200',
|
|
||||||
'hover:bg-primary-200/70 dark:hover:bg-primary-800/50',
|
|
||||||
'transition-colors',
|
|
||||||
]"
|
|
||||||
:disabled="isCreatingSession"
|
|
||||||
@click="startNewSession"
|
|
||||||
>
|
|
||||||
{{ t('stage.chat.sessions.new') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div :class="['flex-1 overflow-y-auto px-2 pb-2']">
|
|
||||||
<div v-if="rows.length === 0" :class="['p-6 text-center text-sm text-neutral-500 dark:text-neutral-400']">
|
|
||||||
{{ t('stage.chat.sessions.empty') }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="row in rows"
|
|
||||||
:key="row.meta.sessionId"
|
|
||||||
:class="[
|
|
||||||
'group relative w-full rounded-xl mb-1',
|
|
||||||
'transition-colors',
|
|
||||||
row.isActive
|
|
||||||
? 'bg-primary-100/70 dark:bg-primary-900/40'
|
|
||||||
: 'hover:bg-neutral-100/80 dark:hover:bg-neutral-800/60',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
:class="['w-full text-left px-3 py-3 outline-none flex flex-col gap-1']"
|
|
||||||
@click="selectSession(row.meta.sessionId)"
|
|
||||||
>
|
|
||||||
<div :class="['flex items-center gap-2 text-sm font-medium text-neutral-700 dark:text-neutral-200']">
|
|
||||||
<span :class="['truncate flex-1']">{{ row.preview }}</span>
|
|
||||||
<span
|
|
||||||
v-if="row.meta.cloudChatId"
|
|
||||||
:class="['shrink-0 text-[10px] uppercase tracking-wide rounded px-1.5 py-0.5', 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300']"
|
|
||||||
:title="t('stage.chat.sessions.cloud-badge')"
|
|
||||||
>
|
|
||||||
cloud
|
|
||||||
</span>
|
|
||||||
<span :class="['w-7']" />
|
|
||||||
</div>
|
|
||||||
<div :class="['text-[11px] text-neutral-500 dark:text-neutral-400']">
|
|
||||||
{{ row.updatedAtLabel }}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="[
|
|
||||||
'absolute right-2 top-2 h-7 w-7 flex items-center justify-center rounded-md',
|
|
||||||
'opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100',
|
|
||||||
'text-neutral-400 hover:text-red-500 hover:bg-red-500/10',
|
|
||||||
'transition-opacity duration-150',
|
|
||||||
]"
|
|
||||||
:title="t('stage.chat.sessions.delete')"
|
|
||||||
@click="deleteRow($event, row.meta.sessionId)"
|
|
||||||
>
|
|
||||||
<div class="i-solar:trash-bin-trash-bold-duotone h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DrawerContent>
|
|
||||||
</DrawerPortal>
|
|
||||||
</DrawerRoot>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -239,11 +239,13 @@ describe('store character-orchestrator', () => {
|
|||||||
forceTextResponse: true,
|
forceTextResponse: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const streamOptions = mockStream.mock.calls[0][3]
|
const streamOptions = mockStream.mock.lastCall?.[3]
|
||||||
expect(streamOptions.supportsTools).toBe(false)
|
expect(streamOptions).toMatchObject({
|
||||||
expect(streamOptions.waitForTools).toBe(false)
|
supportsTools: false,
|
||||||
expect(streamOptions.tools).toEqual([])
|
tools: [],
|
||||||
expect(streamOptions.toolChoice).toBeUndefined()
|
waitForTools: false,
|
||||||
|
})
|
||||||
|
expect(streamOptions?.toolChoice).toBeUndefined()
|
||||||
expect(onDelta).toHaveBeenCalled()
|
expect(onDelta).toHaveBeenCalled()
|
||||||
expect(onEnd).toHaveBeenCalled()
|
expect(onEnd).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
@@ -290,12 +292,14 @@ describe('store character-orchestrator', () => {
|
|||||||
forceSparkCommandResponse: true,
|
forceSparkCommandResponse: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const streamOptions = mockStream.mock.calls[0][3]
|
const streamOptions = mockStream.mock.lastCall?.[3]
|
||||||
expect(streamOptions.supportsTools).toBe(true)
|
expect(streamOptions).toMatchObject({
|
||||||
expect(streamOptions.waitForTools).toBe(true)
|
supportsTools: true,
|
||||||
expect(streamOptions.toolChoice).toEqual({
|
toolChoice: {
|
||||||
type: 'function',
|
type: 'function',
|
||||||
function: { name: 'builtIn_sparkCommand' },
|
function: { name: 'builtIn_sparkCommand' },
|
||||||
|
},
|
||||||
|
waitForTools: true,
|
||||||
})
|
})
|
||||||
expect(result?.commands?.length).toBe(1)
|
expect(result?.commands?.length).toBe(1)
|
||||||
expect(sendSparkCommandMock).toHaveBeenCalledWith({
|
expect(sendSparkCommandMock).toHaveBeenCalledWith({
|
||||||
@@ -336,7 +340,7 @@ describe('store character-orchestrator', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const renderedMessages = mockStream.mock.calls[0]?.[2] as Array<{ role: string, content: string }> | undefined
|
const renderedMessages = mockStream.mock.lastCall?.[2] as Array<{ role: string, content: string }> | undefined
|
||||||
expect(String(renderedMessages?.[0]?.content)).toContain('Plugin-specific hint')
|
expect(String(renderedMessages?.[0]?.content)).toContain('Plugin-specific hint')
|
||||||
expect(String(renderedMessages?.[1]?.content)).toContain('Rendered board snapshot')
|
expect(String(renderedMessages?.[1]?.content)).toContain('Rendered board snapshot')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
import type { ChatProvider } from '@xsai-ext/providers/utils'
|
||||||
import type { Message, Tool } from '@xsai/shared-chat'
|
import type { Message, Tool } from '@xsai/shared-chat'
|
||||||
|
|
||||||
|
import { errorMessageFrom } from '@moeru/std'
|
||||||
import { IOAttributes, IOSpanNames } from '@proj-airi/stage-shared'
|
import { IOAttributes, IOSpanNames } from '@proj-airi/stage-shared'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
@@ -71,6 +72,8 @@ const createMinecraftContextMock = vi.fn()
|
|||||||
const persistSessionMessagesMock = vi.fn()
|
const persistSessionMessagesMock = vi.fn()
|
||||||
const forkSessionMock = vi.fn()
|
const forkSessionMock = vi.fn()
|
||||||
const ensureSessionMock = vi.fn()
|
const ensureSessionMock = vi.fn()
|
||||||
|
const loadSessionMock = vi.fn()
|
||||||
|
const deleteSessionMock = vi.fn()
|
||||||
const getProviderInstanceMock = vi.fn()
|
const getProviderInstanceMock = vi.fn()
|
||||||
const getToolsByNamesMock = vi.fn<(names: string[]) => Tool[]>()
|
const getToolsByNamesMock = vi.fn<(names: string[]) => Tool[]>()
|
||||||
|
|
||||||
@@ -194,6 +197,9 @@ vi.mock('./chat/session-store', () => ({
|
|||||||
sessionMessages[sessionId] = []
|
sessionMessages[sessionId] = []
|
||||||
},
|
},
|
||||||
getSessionMessages: (sessionId: string) => sessionMessages[sessionId] ?? [],
|
getSessionMessages: (sessionId: string) => sessionMessages[sessionId] ?? [],
|
||||||
|
getSessionMessagesIfLoaded: (sessionId: string) => sessionMessages[sessionId],
|
||||||
|
loadSession: loadSessionMock,
|
||||||
|
deleteSession: deleteSessionMock,
|
||||||
persistSessionMessages: persistSessionMessagesMock,
|
persistSessionMessages: persistSessionMessagesMock,
|
||||||
getSessionGeneration: () => currentGeneration,
|
getSessionGeneration: () => currentGeneration,
|
||||||
setSessionMessages: (sessionId: string, messages: any[]) => {
|
setSessionMessages: (sessionId: string, messages: any[]) => {
|
||||||
@@ -285,6 +291,8 @@ describe('chat store contract', () => {
|
|||||||
persistSessionMessagesMock.mockReset()
|
persistSessionMessagesMock.mockReset()
|
||||||
forkSessionMock.mockReset()
|
forkSessionMock.mockReset()
|
||||||
ensureSessionMock.mockReset()
|
ensureSessionMock.mockReset()
|
||||||
|
loadSessionMock.mockReset().mockResolvedValue(true)
|
||||||
|
deleteSessionMock.mockReset().mockResolvedValue(undefined)
|
||||||
getProviderInstanceMock.mockReset().mockResolvedValue(provider)
|
getProviderInstanceMock.mockReset().mockResolvedValue(provider)
|
||||||
getToolsByNamesMock.mockReset().mockImplementation(names => names.map(name => ({
|
getToolsByNamesMock.mockReset().mockImplementation(names => names.map(name => ({
|
||||||
type: 'function',
|
type: 'function',
|
||||||
@@ -338,6 +346,51 @@ describe('chat store contract', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// https://github.com/moeru-ai/airi/issues/2085
|
||||||
|
it('hydrates the target session before sending for Issue #2085', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// A synchronized follower could target a session known only by metadata.
|
||||||
|
// Reading through getSessionMessages before hydration created a fresh
|
||||||
|
// system-only history that could overwrite the persisted conversation.
|
||||||
|
delete sessionMessages['session-2']
|
||||||
|
loadSessionMock.mockImplementationOnce(async () => {
|
||||||
|
sessionMessages['session-2'] = [
|
||||||
|
{ role: 'system', content: 'persisted system prompt', createdAt: 1, id: 'system-2' },
|
||||||
|
]
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
llmStreamMock.mockImplementationOnce(async (_model: string, _chatProvider: ChatProvider, _messages: Message[], options: any) => {
|
||||||
|
await options.onStreamEvent({ type: 'finish', finishReason: 'stop' })
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useChatStore()
|
||||||
|
await store.send({ sessionId: 'session-2', text: 'continue persisted chat' })
|
||||||
|
|
||||||
|
expect(loadSessionMock).toHaveBeenCalledWith('session-2')
|
||||||
|
expect(loadSessionMock.mock.invocationCallOrder[0]).toBeLessThan(ensureSessionMock.mock.invocationCallOrder[0])
|
||||||
|
expect(sessionMessages['session-2']?.[0]).toMatchObject({
|
||||||
|
content: 'persisted system prompt',
|
||||||
|
id: 'system-2',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/moeru-ai/airi/issues/2085
|
||||||
|
it('does not create fallback history when target hydration fails for Issue #2085', async () => {
|
||||||
|
delete sessionMessages['session-2']
|
||||||
|
loadSessionMock.mockResolvedValueOnce(false)
|
||||||
|
|
||||||
|
const store = useChatStore()
|
||||||
|
await expect(
|
||||||
|
store.send({ sessionId: 'session-2', text: 'do not overwrite history' }),
|
||||||
|
)
|
||||||
|
.rejects
|
||||||
|
.toThrow('Failed to load the target chat session')
|
||||||
|
|
||||||
|
expect(ensureSessionMock).not.toHaveBeenCalledWith('session-2')
|
||||||
|
expect(sessionMessages['session-2']).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
it('forwards one correlation identity across every PostHog chat milestone', async () => {
|
it('forwards one correlation identity across every PostHog chat milestone', async () => {
|
||||||
llmStreamMock.mockImplementation(async (_model: string, _chatProvider: ChatProvider, _messages: Message[], options: any) => {
|
llmStreamMock.mockImplementation(async (_model: string, _chatProvider: ChatProvider, _messages: Message[], options: any) => {
|
||||||
await options.onStreamEvent({ type: 'text-delta', text: 'ok' })
|
await options.onStreamEvent({ type: 'text-delta', text: 'ok' })
|
||||||
@@ -650,30 +703,31 @@ describe('chat store contract', () => {
|
|||||||
expect(specialHook.mock.calls[0]?.[1].turnId.length).toBeGreaterThan(0)
|
expect(specialHook.mock.calls[0]?.[1].turnId.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743261505
|
||||||
* @example
|
it('preserves a synchronized sending snapshot without replaying it through the follower runtime for Issue #2085', async () => {
|
||||||
* store.sending = true
|
// ROOT CAUSE:
|
||||||
* await nextTick()
|
//
|
||||||
* expect(store.sending).toBe(true)
|
// Applying `sending: true` invoked the follower's idle runtime, whose
|
||||||
*/
|
// derived state cleared the synchronized stream payload and could target
|
||||||
it('keeps sending writable for context bridge and chat sync consumers', async () => {
|
// that follower's unrelated local selection.
|
||||||
const store = useChatStore()
|
const store = useChatStore()
|
||||||
|
store.$patch({
|
||||||
expect(store.sending).toBe(false)
|
sending: true,
|
||||||
|
activeSendSessionId: 'session-b',
|
||||||
store.sending = true
|
activeStreamingMessage: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'authority stream',
|
||||||
|
slices: [],
|
||||||
|
tool_results: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
expect(store.sending).toBe(true)
|
expect(store.sending).toBe(true)
|
||||||
|
expect(store.activeSendSessionId).toBe('session-b')
|
||||||
store.sending = false
|
expect(store.activeStreamingMessage?.content).toBe('authority stream')
|
||||||
await nextTick()
|
|
||||||
expect(store.sending).toBe(false)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* @example
|
|
||||||
* store.sending = false while a local runtime send is still streaming.
|
|
||||||
*/
|
|
||||||
it('does not end the owned IO turn span when external sending mirror is cleared mid-send', async () => {
|
it('does not end the owned IO turn span when external sending mirror is cleared mid-send', async () => {
|
||||||
let releaseStream: (() => void) | undefined
|
let releaseStream: (() => void) | undefined
|
||||||
llmStreamMock.mockImplementationOnce(async () => {
|
llmStreamMock.mockImplementationOnce(async () => {
|
||||||
@@ -711,11 +765,6 @@ describe('chat store contract', () => {
|
|||||||
expect(ioTracerMocks.activeTurnSpan.value).toBeUndefined()
|
expect(ioTracerMocks.activeTurnSpan.value).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* @example
|
|
||||||
* createMinecraftContext() returns a runtime context update.
|
|
||||||
* The facade passes it into the core runtime before prompt snapshots are read.
|
|
||||||
*/
|
|
||||||
it('ingests runtime context providers before composing prompt snapshots', async () => {
|
it('ingests runtime context providers before composing prompt snapshots', async () => {
|
||||||
const minecraftContext = {
|
const minecraftContext = {
|
||||||
id: 'minecraft-context',
|
id: 'minecraft-context',
|
||||||
@@ -788,11 +837,60 @@ describe('chat store contract', () => {
|
|||||||
await firstSend
|
await firstSend
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3742939573
|
||||||
* @example
|
it('does not recreate a deleted session when queued work is cancelled for Issue #2085', async () => {
|
||||||
* store.getPendingQueuedSendSnapshot()
|
// ROOT CAUSE:
|
||||||
* // => [{ sessionId, generation, cancelled, messagePreview, hasAttachments, inputType }]
|
//
|
||||||
*/
|
// Cancelling queued work rejects the public send action. Its generic error
|
||||||
|
// handler used to recreate a system-plus-error history after deletion,
|
||||||
|
// leaving a ghost conversation that no longer had session metadata.
|
||||||
|
let releaseFirstSend: (() => void) | undefined
|
||||||
|
llmStreamMock.mockImplementationOnce(async () => {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
releaseFirstSend = resolve
|
||||||
|
})
|
||||||
|
})
|
||||||
|
deleteSessionMock.mockImplementationOnce(async () => {
|
||||||
|
currentGeneration += 1
|
||||||
|
delete sessionMessages['session-1']
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = useChatStore()
|
||||||
|
const firstSend = store.send({
|
||||||
|
sessionId: 'session-1',
|
||||||
|
text: 'active turn',
|
||||||
|
})
|
||||||
|
const activeOutcome = firstSend.then(
|
||||||
|
() => 'resolved',
|
||||||
|
error => errorMessageFrom(error) ?? 'unknown error',
|
||||||
|
)
|
||||||
|
const queuedSend = store.send({
|
||||||
|
sessionId: 'session-1',
|
||||||
|
text: 'must be cancelled',
|
||||||
|
})
|
||||||
|
const queuedOutcome = queuedSend.then(
|
||||||
|
() => 'resolved',
|
||||||
|
error => errorMessageFrom(error) ?? 'unknown error',
|
||||||
|
)
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(llmStreamMock).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(store.pendingQueuedSendCount).toBe(1)
|
||||||
|
})
|
||||||
|
await store.deleteSession('session-1')
|
||||||
|
|
||||||
|
expect(deleteSessionMock).toHaveBeenCalledWith('session-1')
|
||||||
|
expect(await queuedOutcome).toBe('Chat session was reset before send could start')
|
||||||
|
expect(sessionMessages['session-1']).toBeUndefined()
|
||||||
|
|
||||||
|
releaseFirstSend?.()
|
||||||
|
expect(await activeOutcome).toBe('Chat session was removed before send completed')
|
||||||
|
expect(llmStreamMock).toHaveBeenCalledTimes(1)
|
||||||
|
expect(sessionMessages['session-1']).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
it('mirrors pending queued send snapshots from the core runtime', async () => {
|
it('mirrors pending queued send snapshots from the core runtime', async () => {
|
||||||
let releaseFirstSend: (() => void) | undefined
|
let releaseFirstSend: (() => void) | undefined
|
||||||
llmStreamMock.mockImplementationOnce(async () => {
|
llmStreamMock.mockImplementationOnce(async () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { ChatProvider } from '@xsai-ext/providers/utils'
|
|||||||
import type { Message } from '@xsai/shared-chat'
|
import type { Message } from '@xsai/shared-chat'
|
||||||
import type {} from 'pinia-plugin-synced'
|
import type {} from 'pinia-plugin-synced'
|
||||||
|
|
||||||
import type { ChatHistoryItem, ChatToolReference } from '../types/chat'
|
import type { ChatHistoryItem, ChatToolReference, StreamingAssistantMessage } from '../types/chat'
|
||||||
import type { ToolCallRerunPayload } from './tool-call-rerun'
|
import type { ToolCallRerunPayload } from './tool-call-rerun'
|
||||||
|
|
||||||
import { errorMessageFrom } from '@moeru/std'
|
import { errorMessageFrom } from '@moeru/std'
|
||||||
@@ -12,7 +12,7 @@ import { createChatOrchestratorRuntime } from '@proj-airi/core-agent'
|
|||||||
import { IOAttributes, IOEvents, IOSpanNames, IOSubsystems } from '@proj-airi/stage-shared'
|
import { IOAttributes, IOEvents, IOSpanNames, IOSubsystems } from '@proj-airi/stage-shared'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { defineStore, storeToRefs } from 'pinia'
|
import { defineStore, storeToRefs } from 'pinia'
|
||||||
import { ref, toRaw, watch } from 'vue'
|
import { shallowRef, toRaw } from 'vue'
|
||||||
|
|
||||||
import { getConversationAnalyticsSurface } from '../composables'
|
import { getConversationAnalyticsSurface } from '../composables'
|
||||||
import { activeTurnSpan, startSpan } from '../composables/use-io-tracer'
|
import { activeTurnSpan, startSpan } from '../composables/use-io-tracer'
|
||||||
@@ -157,8 +157,10 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
const { activeSessionId } = storeToRefs(chatSession)
|
const { activeSessionId } = storeToRefs(chatSession)
|
||||||
const { streamingMessage } = storeToRefs(chatStream)
|
const { streamingMessage } = storeToRefs(chatStream)
|
||||||
|
|
||||||
const sending = ref(false)
|
const sending = shallowRef(false)
|
||||||
const pendingQueuedSendCount = ref(0)
|
const activeSendSessionId = shallowRef<string>()
|
||||||
|
const activeStreamingMessage = shallowRef<StreamingAssistantMessage>()
|
||||||
|
const pendingQueuedSendCount = shallowRef(0)
|
||||||
let ownedActiveTurnSpan: typeof activeTurnSpan.value
|
let ownedActiveTurnSpan: typeof activeTurnSpan.value
|
||||||
const analyticsHooks = createChatAnalyticsHooks({
|
const analyticsHooks = createChatAnalyticsHooks({
|
||||||
getSessionMessages: sessionId => chatSession.getSessionMessages(sessionId),
|
getSessionMessages: sessionId => chatSession.getSessionMessages(sessionId),
|
||||||
@@ -229,6 +231,8 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
|
|
||||||
function syncRuntimeState(state: ChatOrchestratorRuntimeState) {
|
function syncRuntimeState(state: ChatOrchestratorRuntimeState) {
|
||||||
sending.value = state.sending
|
sending.value = state.sending
|
||||||
|
activeSendSessionId.value = state.activeSendSessionId
|
||||||
|
activeStreamingMessage.value = state.activeStreamingMessage
|
||||||
pendingQueuedSendCount.value = state.pendingQueuedSendCount
|
pendingQueuedSendCount.value = state.pendingQueuedSendCount
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,11 +321,6 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(sending, (next) => {
|
|
||||||
if (runtime.getSending() !== next)
|
|
||||||
runtime.setSending(next)
|
|
||||||
})
|
|
||||||
|
|
||||||
async function ingest(
|
async function ingest(
|
||||||
sendingMessage: string,
|
sendingMessage: string,
|
||||||
options: ChatOrchestratorSendOptions,
|
options: ChatOrchestratorSendOptions,
|
||||||
@@ -345,6 +344,9 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function appendSendError(sessionId: string, error: unknown) {
|
function appendSendError(sessionId: string, error: unknown) {
|
||||||
|
if (!chatSession.getSessionMessagesIfLoaded(sessionId))
|
||||||
|
return
|
||||||
|
|
||||||
chatSession.appendSessionMessage(sessionId, {
|
chatSession.appendSessionMessage(sessionId, {
|
||||||
role: 'error',
|
role: 'error',
|
||||||
content: errorMessageFrom(error) ?? 'Unknown chat operation failure',
|
content: errorMessageFrom(error) ?? 'Unknown chat operation failure',
|
||||||
@@ -357,6 +359,9 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
if (!providerId || !modelId)
|
if (!providerId || !modelId)
|
||||||
throw new Error('No active chat provider or model configured')
|
throw new Error('No active chat provider or model configured')
|
||||||
|
|
||||||
|
if (!await chatSession.loadSession(payload.sessionId))
|
||||||
|
throw new Error('Failed to load the target chat session')
|
||||||
|
|
||||||
const messageCount = chatSession.getSessionMessages(payload.sessionId).length
|
const messageCount = chatSession.getSessionMessages(payload.sessionId).length
|
||||||
const chatProvider = await providerStore.getProviderInstance<ChatProvider>(providerId)
|
const chatProvider = await providerStore.getProviderInstance<ChatProvider>(providerId)
|
||||||
if (!chatProvider)
|
if (!chatProvider)
|
||||||
@@ -376,8 +381,12 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
},
|
},
|
||||||
}, payload.sessionId)
|
}, payload.sessionId)
|
||||||
|
|
||||||
|
const completedMessages = chatSession.getSessionMessagesIfLoaded(payload.sessionId)
|
||||||
|
if (!completedMessages)
|
||||||
|
throw new Error('Chat session was removed before send completed')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages: chatSession.getSessionMessages(payload.sessionId)
|
messages: completedMessages
|
||||||
.slice(messageCount)
|
.slice(messageCount)
|
||||||
.map(message => structuredClone(toRaw(message))),
|
.map(message => structuredClone(toRaw(message))),
|
||||||
sessionId: payload.sessionId,
|
sessionId: payload.sessionId,
|
||||||
@@ -397,6 +406,9 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
|
|
||||||
/** Replaces one stored turn with a new execution of its user message. */
|
/** Replaces one stored turn with a new execution of its user message. */
|
||||||
async function retry(payload: ChatRetryPayload): Promise<ChatSendResult> {
|
async function retry(payload: ChatRetryPayload): Promise<ChatSendResult> {
|
||||||
|
if (!await chatSession.loadSession(payload.sessionId))
|
||||||
|
throw new Error('Failed to load the target chat session')
|
||||||
|
|
||||||
const currentMessages = chatSession.getSessionMessages(payload.sessionId)
|
const currentMessages = chatSession.getSessionMessages(payload.sessionId)
|
||||||
const sourceIndex = retrySourceIndexFrom(currentMessages, payload.index)
|
const sourceIndex = retrySourceIndexFrom(currentMessages, payload.index)
|
||||||
if (sourceIndex < 0)
|
if (sourceIndex < 0)
|
||||||
@@ -424,6 +436,9 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
|
|
||||||
/** Runs one stored tool call again and replaces its stored result. */
|
/** Runs one stored tool call again and replaces its stored result. */
|
||||||
async function rerunToolCall(payload: ChatToolCallRerunPayload): Promise<void> {
|
async function rerunToolCall(payload: ChatToolCallRerunPayload): Promise<void> {
|
||||||
|
if (!await chatSession.loadSession(payload.sessionId))
|
||||||
|
throw new Error('Failed to load the target chat session')
|
||||||
|
|
||||||
const nextMessages = await executeToolCallRerun({
|
const nextMessages = await executeToolCallRerun({
|
||||||
messages: chatSession.getSessionMessages(payload.sessionId),
|
messages: chatSession.getSessionMessages(payload.sessionId),
|
||||||
payload,
|
payload,
|
||||||
@@ -442,6 +457,12 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
chatStream.resetStream()
|
chatStream.resetStream()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cancels queued work before permanently removing its owning session. */
|
||||||
|
function deleteSession(sessionId: string): Promise<void> {
|
||||||
|
runtime.cancelPendingSends(sessionId)
|
||||||
|
return chatSession.deleteSession(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
async function ingestOnFork(
|
async function ingestOnFork(
|
||||||
sendingMessage: string,
|
sendingMessage: string,
|
||||||
options: ChatOrchestratorSendOptions,
|
options: ChatOrchestratorSendOptions,
|
||||||
@@ -470,9 +491,12 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
sending,
|
sending,
|
||||||
|
activeSendSessionId,
|
||||||
|
activeStreamingMessage,
|
||||||
pendingQueuedSendCount,
|
pendingQueuedSendCount,
|
||||||
|
|
||||||
cleanup,
|
cleanup,
|
||||||
|
deleteSession,
|
||||||
ingest,
|
ingest,
|
||||||
ingestOnFork,
|
ingestOnFork,
|
||||||
rerunToolCall,
|
rerunToolCall,
|
||||||
@@ -507,7 +531,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
synced: {
|
synced: {
|
||||||
actions: ['cleanup', 'rerunToolCall', 'retry', 'send'],
|
actions: ['cleanup', 'deleteSession', 'rerunToolCall', 'retry', 'send'],
|
||||||
state: true,
|
state: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ChatSessionMeta, ChatSessionRecord, ChatSessionsIndex } from '../../types/chat-session'
|
import type { ChatSessionMeta, ChatSessionRecord, ChatSessionsIndex } from '../../types/chat-session'
|
||||||
|
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, disposePinia, setActivePinia } from 'pinia'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { nextTick, ref } from 'vue'
|
import { nextTick, ref } from 'vue'
|
||||||
|
|
||||||
// Refs the store reads through the mocked `useAuthStore` / `useAiriCardStore`.
|
// Refs the store reads through the mocked `useAuthStore` / `useAiriCardStore`.
|
||||||
@@ -19,6 +19,14 @@ const getOutboxMock = vi.fn<(uid: string) => Promise<any[]>>()
|
|||||||
const dropOutboxForSessionMock = vi.fn<(uid: string, id: string) => Promise<void>>()
|
const dropOutboxForSessionMock = vi.fn<(uid: string, id: string) => Promise<void>>()
|
||||||
const getTombstonesMock = vi.fn<(uid: string) => Promise<string[]>>()
|
const getTombstonesMock = vi.fn<(uid: string) => Promise<string[]>>()
|
||||||
const removeTombstonesMock = vi.fn<(uid: string, ids: string[]) => Promise<void>>()
|
const removeTombstonesMock = vi.fn<(uid: string, ids: string[]) => Promise<void>>()
|
||||||
|
const addTombstoneMock = vi.fn<(uid: string, id: string) => Promise<void>>()
|
||||||
|
const deleteCloudChatMock = vi.fn<(id: string) => Promise<void>>()
|
||||||
|
const listChatsMock = vi.fn()
|
||||||
|
const pullMessagesMock = vi.fn()
|
||||||
|
const reconcileLocalAndRemoteMock = vi.fn()
|
||||||
|
const connectCloudWsMock = vi.fn()
|
||||||
|
let cloudWsStatus: 'idle' | 'open' = 'idle'
|
||||||
|
let cloudStatusListener: ((status: 'idle' | 'open') => void) | undefined
|
||||||
|
|
||||||
vi.mock('pinia', async () => {
|
vi.mock('pinia', async () => {
|
||||||
const actual = await vi.importActual<typeof import('pinia')>('pinia')
|
const actual = await vi.importActual<typeof import('pinia')>('pinia')
|
||||||
@@ -52,7 +60,7 @@ vi.mock('../../database/repos/chat-sessions.repo', () => ({
|
|||||||
updateOutboxEntries: vi.fn().mockResolvedValue(undefined),
|
updateOutboxEntries: vi.fn().mockResolvedValue(undefined),
|
||||||
dropOutboxForSession: (uid: string, id: string) => dropOutboxForSessionMock(uid, id),
|
dropOutboxForSession: (uid: string, id: string) => dropOutboxForSessionMock(uid, id),
|
||||||
getTombstones: (uid: string) => getTombstonesMock(uid),
|
getTombstones: (uid: string) => getTombstonesMock(uid),
|
||||||
addTombstone: vi.fn().mockResolvedValue(undefined),
|
addTombstone: (uid: string, id: string) => addTombstoneMock(uid, id),
|
||||||
removeTombstones: (uid: string, ids: string[]) => removeTombstonesMock(uid, ids),
|
removeTombstones: (uid: string, ids: string[]) => removeTombstonesMock(uid, ids),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
@@ -74,20 +82,23 @@ vi.mock('../../libs/server', () => ({
|
|||||||
// sufficient. We keep `extractMessageText` realistic so message previews work.
|
// sufficient. We keep `extractMessageText` realistic so message previews work.
|
||||||
vi.mock('../../libs/chat-sync', () => ({
|
vi.mock('../../libs/chat-sync', () => ({
|
||||||
applyCreateActions: vi.fn().mockResolvedValue([]),
|
applyCreateActions: vi.fn().mockResolvedValue([]),
|
||||||
reconcileLocalAndRemote: vi.fn().mockReturnValue({ adopt: [], claim: [], create: [] }),
|
reconcileLocalAndRemote: (...args: unknown[]) => reconcileLocalAndRemoteMock(...args),
|
||||||
createCloudChatMapper: () => ({
|
createCloudChatMapper: () => ({
|
||||||
listChats: vi.fn().mockResolvedValue([]),
|
listChats: () => listChatsMock(),
|
||||||
deleteChat: vi.fn().mockResolvedValue(undefined),
|
deleteChat: (id: string) => deleteCloudChatMock(id),
|
||||||
}),
|
}),
|
||||||
createChatWsClient: () => ({
|
createChatWsClient: () => ({
|
||||||
status: () => 'idle' as const,
|
status: () => cloudWsStatus,
|
||||||
connect: vi.fn(),
|
connect: connectCloudWsMock,
|
||||||
disconnect: vi.fn(),
|
disconnect: vi.fn(),
|
||||||
destroy: vi.fn(),
|
destroy: vi.fn(),
|
||||||
sendMessages: vi.fn().mockResolvedValue({ ok: true }),
|
sendMessages: vi.fn().mockResolvedValue({ ok: true }),
|
||||||
pullMessages: vi.fn().mockResolvedValue({ messages: [], maxSeq: 0 }),
|
pullMessages: (...args: unknown[]) => pullMessagesMock(...args),
|
||||||
onNewMessages: () => () => {},
|
onNewMessages: () => () => {},
|
||||||
onStatusChange: () => () => {},
|
onStatusChange: (listener: (status: 'idle' | 'open') => void) => {
|
||||||
|
cloudStatusListener = listener
|
||||||
|
return () => {}
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
extractMessageText: (m: any) => (typeof m?.content === 'string' ? m.content : ''),
|
extractMessageText: (m: any) => (typeof m?.content === 'string' ? m.content : ''),
|
||||||
isCloudSyncableMessage: () => false,
|
isCloudSyncableMessage: () => false,
|
||||||
@@ -95,9 +106,11 @@ vi.mock('../../libs/chat-sync', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const { useChatSessionStore } = await import('./session-store')
|
const { useChatSessionStore } = await import('./session-store')
|
||||||
|
let pinia: ReturnType<typeof createPinia>
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
userIdRef.value = 'local'
|
userIdRef.value = 'local'
|
||||||
activeCardIdRef.value = 'default'
|
activeCardIdRef.value = 'default'
|
||||||
systemPromptRef.value = ''
|
systemPromptRef.value = ''
|
||||||
@@ -111,6 +124,18 @@ beforeEach(() => {
|
|||||||
dropOutboxForSessionMock.mockReset().mockResolvedValue(undefined)
|
dropOutboxForSessionMock.mockReset().mockResolvedValue(undefined)
|
||||||
getTombstonesMock.mockReset().mockResolvedValue([])
|
getTombstonesMock.mockReset().mockResolvedValue([])
|
||||||
removeTombstonesMock.mockReset().mockResolvedValue(undefined)
|
removeTombstonesMock.mockReset().mockResolvedValue(undefined)
|
||||||
|
addTombstoneMock.mockReset().mockResolvedValue(undefined)
|
||||||
|
deleteCloudChatMock.mockReset().mockResolvedValue(undefined)
|
||||||
|
listChatsMock.mockReset().mockResolvedValue([])
|
||||||
|
pullMessagesMock.mockReset().mockResolvedValue({ messages: [], seq: 0 })
|
||||||
|
reconcileLocalAndRemoteMock.mockReset().mockReturnValue({ adopt: [], claim: [], create: [] })
|
||||||
|
connectCloudWsMock.mockReset()
|
||||||
|
cloudWsStatus = 'idle'
|
||||||
|
cloudStatusListener = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
disposePinia(pinia)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function flushMicrotasks(rounds = 8) {
|
async function flushMicrotasks(rounds = 8) {
|
||||||
@@ -295,6 +320,291 @@ describe('chat-session-store · loadSession vs concurrent deleteSession', () =>
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('chat-session-store · deletion and hydration failures', () => {
|
||||||
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743502031
|
||||||
|
it('persists the fallback without changing the leader selection when a follower deletes its active session for Issue #2085', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// Session selection is window-local, so the synchronized leader can be on
|
||||||
|
// A while the persisted index still points to B. Deleting B returned A but
|
||||||
|
// left the persisted index empty, so the next startup created a blank chat.
|
||||||
|
const sessionA: ChatSessionMeta = {
|
||||||
|
sessionId: 'session-a',
|
||||||
|
userId: 'local',
|
||||||
|
characterId: 'default',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
}
|
||||||
|
const sessionB: ChatSessionMeta = {
|
||||||
|
sessionId: 'session-b',
|
||||||
|
userId: 'local',
|
||||||
|
characterId: 'default',
|
||||||
|
createdAt: 2,
|
||||||
|
updatedAt: 2,
|
||||||
|
}
|
||||||
|
const store = useChatSessionStore()
|
||||||
|
store.applyRemoteSnapshot({
|
||||||
|
activeSessionId: 'session-a',
|
||||||
|
sessionMessages: { 'session-a': [], 'session-b': [] },
|
||||||
|
sessionMetas: { 'session-a': sessionA, 'session-b': sessionB },
|
||||||
|
index: {
|
||||||
|
userId: 'local',
|
||||||
|
characters: {
|
||||||
|
default: {
|
||||||
|
activeSessionId: 'session-b',
|
||||||
|
sessions: { 'session-a': sessionA, 'session-b': sessionB },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await store.deleteSession('session-b')
|
||||||
|
|
||||||
|
expect(store.activeSessionId).toBe('session-a')
|
||||||
|
expect(store.getSnapshot().index?.characters.default?.activeSessionId).toBe('session-a')
|
||||||
|
expect(store.sessionMetas['session-b']).toBeUndefined()
|
||||||
|
expect(saveIndexMock).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||||
|
characters: expect.objectContaining({
|
||||||
|
default: expect.objectContaining({ activeSessionId: 'session-a' }),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743309237
|
||||||
|
it('persists a replacement fallback without changing an unrelated leader selection for Issue #2085', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// When a follower deleted the final session for one character, the leader
|
||||||
|
// created a replacement with local activation disabled. The replacement
|
||||||
|
// was indexed but the persisted character active ID stayed empty, so the
|
||||||
|
// next initialization created another blank conversation.
|
||||||
|
const leaderSession: ChatSessionMeta = {
|
||||||
|
sessionId: 'leader-session',
|
||||||
|
userId: 'local',
|
||||||
|
characterId: 'other-character',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
}
|
||||||
|
const deletedSession: ChatSessionMeta = {
|
||||||
|
sessionId: 'deleted-session',
|
||||||
|
userId: 'local',
|
||||||
|
characterId: 'default',
|
||||||
|
createdAt: 2,
|
||||||
|
updatedAt: 2,
|
||||||
|
}
|
||||||
|
const store = useChatSessionStore()
|
||||||
|
store.applyRemoteSnapshot({
|
||||||
|
activeSessionId: 'leader-session',
|
||||||
|
sessionMessages: { 'leader-session': [], 'deleted-session': [] },
|
||||||
|
sessionMetas: { 'leader-session': leaderSession, 'deleted-session': deletedSession },
|
||||||
|
index: {
|
||||||
|
userId: 'local',
|
||||||
|
characters: {
|
||||||
|
'other-character': {
|
||||||
|
activeSessionId: 'leader-session',
|
||||||
|
sessions: { 'leader-session': leaderSession },
|
||||||
|
},
|
||||||
|
'default': {
|
||||||
|
activeSessionId: 'deleted-session',
|
||||||
|
sessions: { 'deleted-session': deletedSession },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await store.deleteSession('deleted-session')
|
||||||
|
const snapshot = store.getSnapshot()
|
||||||
|
const [replacementSessionId] = Object.keys(snapshot.index?.characters.default?.sessions ?? {})
|
||||||
|
expect(replacementSessionId).toBeDefined()
|
||||||
|
if (!replacementSessionId)
|
||||||
|
throw new Error('Expected deletion to create a replacement session')
|
||||||
|
|
||||||
|
expect(store.activeSessionId).toBe('leader-session')
|
||||||
|
expect(snapshot.index?.characters.default?.activeSessionId).toBe(replacementSessionId)
|
||||||
|
expect(snapshot.index?.characters.default?.sessions[replacementSessionId]).toBeDefined()
|
||||||
|
expect(saveIndexMock).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||||
|
characters: expect.objectContaining({
|
||||||
|
default: expect.objectContaining({ activeSessionId: replacementSessionId }),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3628917803
|
||||||
|
it('keeps deleted session generations invalid for Issue #2085', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// Deletion previously removed the generation entry. A send captured at
|
||||||
|
// generation zero could then read the deleted session as generation zero
|
||||||
|
// again and continue appending messages after the chat was gone.
|
||||||
|
const meta: ChatSessionMeta = {
|
||||||
|
sessionId: 'sess-1',
|
||||||
|
userId: 'local',
|
||||||
|
characterId: 'default',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
}
|
||||||
|
const store = useChatSessionStore()
|
||||||
|
store.applyRemoteSnapshot({
|
||||||
|
activeSessionId: 'sess-1',
|
||||||
|
sessionMessages: { 'sess-1': [] },
|
||||||
|
sessionMetas: { 'sess-1': meta },
|
||||||
|
index: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.getSessionGeneration('sess-1')).toBe(0)
|
||||||
|
|
||||||
|
await store.deleteSession('sess-1')
|
||||||
|
|
||||||
|
expect(store.getSessionGeneration('sess-1')).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3628003766
|
||||||
|
it('reports hydration failure and permits a later retry for Issue #2085', async () => {
|
||||||
|
const meta: ChatSessionMeta = {
|
||||||
|
sessionId: 'sess-1',
|
||||||
|
userId: 'local',
|
||||||
|
characterId: 'default',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
}
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
getSessionMock
|
||||||
|
.mockRejectedValueOnce(new Error('IndexedDB read failed'))
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
|
||||||
|
userIdRef.value = 'local'
|
||||||
|
const store = useChatSessionStore()
|
||||||
|
store.applyRemoteSnapshot({
|
||||||
|
activeSessionId: '',
|
||||||
|
sessionMessages: {},
|
||||||
|
sessionMetas: { 'sess-1': meta },
|
||||||
|
index: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(store.loadSession('sess-1')).resolves.toBe(false)
|
||||||
|
await expect(store.loadSession('sess-1')).resolves.toBe(true)
|
||||||
|
|
||||||
|
expect(getSessionMock).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('chat-session-store · cloud placeholder hydration', () => {
|
||||||
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743502032
|
||||||
|
it('retries cloud hydration when the reconcile pull for an adopted placeholder fails for Issue #2085', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// Reconcile creates a system-only placeholder before its first cloud pull.
|
||||||
|
// If that pull fails, loadSession sees the message-map entry and marks the
|
||||||
|
// placeholder as loaded. Selecting the chat then skips every later pull.
|
||||||
|
const localMeta: ChatSessionMeta = {
|
||||||
|
sessionId: 'local-session',
|
||||||
|
userId: 'cloud-user',
|
||||||
|
characterId: 'default',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
}
|
||||||
|
const remoteChat = {
|
||||||
|
id: 'remote-session',
|
||||||
|
type: 'bot' as const,
|
||||||
|
title: null,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
}
|
||||||
|
userIdRef.value = 'cloud-user'
|
||||||
|
getIndexMock.mockResolvedValue({
|
||||||
|
userId: 'cloud-user',
|
||||||
|
characters: {
|
||||||
|
default: {
|
||||||
|
activeSessionId: localMeta.sessionId,
|
||||||
|
sessions: { [localMeta.sessionId]: localMeta },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
getSessionMock.mockImplementation((sessionId) => {
|
||||||
|
if (sessionId === remoteChat.id) {
|
||||||
|
return Promise.resolve({
|
||||||
|
meta: {
|
||||||
|
sessionId: remoteChat.id,
|
||||||
|
userId: 'cloud-user',
|
||||||
|
characterId: 'default',
|
||||||
|
createdAt: Date.parse(remoteChat.createdAt),
|
||||||
|
updatedAt: Date.parse(remoteChat.updatedAt),
|
||||||
|
cloudChatId: remoteChat.id,
|
||||||
|
},
|
||||||
|
messages: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.resolve({ meta: localMeta, messages: [] })
|
||||||
|
})
|
||||||
|
listChatsMock.mockResolvedValue([remoteChat])
|
||||||
|
reconcileLocalAndRemoteMock.mockReturnValue({ adopt: [remoteChat], claim: [], create: [] })
|
||||||
|
pullMessagesMock
|
||||||
|
.mockRejectedValueOnce(new Error('temporary cloud failure'))
|
||||||
|
.mockResolvedValueOnce({ messages: [], seq: 0 })
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
|
||||||
|
const store = useChatSessionStore()
|
||||||
|
await store.initialize()
|
||||||
|
expect(cloudStatusListener).toBeDefined()
|
||||||
|
|
||||||
|
cloudWsStatus = 'open'
|
||||||
|
cloudStatusListener?.('open')
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(store.cloudSyncReady).toBe(true)
|
||||||
|
})
|
||||||
|
expect(pullMessagesMock).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
await store.setActiveSession(remoteChat.id)
|
||||||
|
|
||||||
|
expect(pullMessagesMock).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('chat-session-store · cloud deletion', () => {
|
||||||
|
it('tombstones an unmapped cloud session before an in-flight create can finish', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// A newly created cloud session can be deleted before POST /chats binds
|
||||||
|
// its cloud id. Without a tombstone for the deterministic session id, the
|
||||||
|
// completed remote create is adopted again by the next reconcile.
|
||||||
|
userIdRef.value = 'cloud-user'
|
||||||
|
const deleted: ChatSessionMeta = {
|
||||||
|
sessionId: 'pending-cloud-session',
|
||||||
|
userId: 'cloud-user',
|
||||||
|
characterId: 'default',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
}
|
||||||
|
const survivor: ChatSessionMeta = {
|
||||||
|
sessionId: 'surviving-session',
|
||||||
|
userId: 'cloud-user',
|
||||||
|
characterId: 'default',
|
||||||
|
createdAt: 2,
|
||||||
|
updatedAt: 2,
|
||||||
|
}
|
||||||
|
const store = useChatSessionStore()
|
||||||
|
store.applyRemoteSnapshot({
|
||||||
|
activeSessionId: 'surviving-session',
|
||||||
|
sessionMessages: { 'pending-cloud-session': [], 'surviving-session': [] },
|
||||||
|
sessionMetas: { 'pending-cloud-session': deleted, 'surviving-session': survivor },
|
||||||
|
index: {
|
||||||
|
userId: 'cloud-user',
|
||||||
|
characters: {
|
||||||
|
default: {
|
||||||
|
activeSessionId: 'pending-cloud-session',
|
||||||
|
sessions: { 'pending-cloud-session': deleted, 'surviving-session': survivor },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await store.deleteSession('pending-cloud-session')
|
||||||
|
|
||||||
|
expect(addTombstoneMock).toHaveBeenCalledWith('cloud-user', 'pending-cloud-session')
|
||||||
|
expect(deleteCloudChatMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('chat-session-store · active card prompt edits', () => {
|
describe('chat-session-store · active card prompt edits', () => {
|
||||||
// ROOT CAUSE:
|
// ROOT CAUSE:
|
||||||
//
|
//
|
||||||
@@ -394,6 +704,257 @@ describe('chat-session-store · active card prompt edits', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('chat-session-store · synchronized data actions', () => {
|
describe('chat-session-store · synchronized data actions', () => {
|
||||||
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3755711151
|
||||||
|
it('keeps cloud synchronization in the elected leader for Issue #2085', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// Window-local initialization opened a cloud WebSocket in every window.
|
||||||
|
// Follower callbacks then proposed direct full-state mutations.
|
||||||
|
const store = useChatSessionStore()
|
||||||
|
store.setCloudSyncOwnership(false)
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
userIdRef.value = 'cloud-user'
|
||||||
|
await nextTick()
|
||||||
|
expect(connectCloudWsMock).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
store.setCloudSyncOwnership(true)
|
||||||
|
expect(connectCloudWsMock).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743242525
|
||||||
|
it('initializes a new window selection from the synchronized index for Issue #2085', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// `ready` was synchronized while selection was not. A joining window saw
|
||||||
|
// the leader's ready flag, skipped initialization, and remained on an
|
||||||
|
// empty local selection.
|
||||||
|
const session: ChatSessionMeta = {
|
||||||
|
sessionId: 'session-b',
|
||||||
|
userId: 'local',
|
||||||
|
characterId: 'default',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
}
|
||||||
|
const store = useChatSessionStore()
|
||||||
|
store.$patch({
|
||||||
|
sessionMessages: { 'session-b': [{ id: 'system', role: 'system', content: 'prompt' }] },
|
||||||
|
sessionMetas: { 'session-b': session },
|
||||||
|
index: {
|
||||||
|
userId: 'local',
|
||||||
|
characters: {
|
||||||
|
default: {
|
||||||
|
activeSessionId: 'session-b',
|
||||||
|
sessions: { 'session-b': session },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.$state).not.toHaveProperty('ready')
|
||||||
|
expect(store.activeSessionId).toBe('')
|
||||||
|
|
||||||
|
await store.initialize()
|
||||||
|
|
||||||
|
expect(store.activeSessionId).toBe('session-b')
|
||||||
|
expect(store.isReady).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743242529
|
||||||
|
it('trusts synchronized messages instead of merging a stale follower IDB record for Issue #2085', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// Follower hydration read its own older IndexedDB record and mutated the
|
||||||
|
// fully synchronized store, allowing that stale snapshot to overwrite the
|
||||||
|
// leader's newer messages or resurrect a deleted session.
|
||||||
|
const session: ChatSessionMeta = {
|
||||||
|
sessionId: 'session-b',
|
||||||
|
userId: 'local',
|
||||||
|
characterId: 'default',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 2,
|
||||||
|
}
|
||||||
|
getSessionMock.mockResolvedValue({
|
||||||
|
meta: { ...session, updatedAt: 1 },
|
||||||
|
messages: [{ id: 'stale', role: 'user', content: 'stale follower data' }],
|
||||||
|
})
|
||||||
|
const store = useChatSessionStore()
|
||||||
|
store.$patch({
|
||||||
|
sessionMessages: {
|
||||||
|
'session-b': [{ id: 'current', role: 'assistant', content: 'leader data', slices: [], tool_results: [] }],
|
||||||
|
},
|
||||||
|
sessionMetas: { 'session-b': session },
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(store.loadSession('session-b')).resolves.toBe(true)
|
||||||
|
|
||||||
|
expect(getSessionMock).not.toHaveBeenCalled()
|
||||||
|
expect(store.getSessionMessagesIfLoaded('session-b')?.map(message => message.id)).toEqual(['current'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refreshes an already loaded session from IndexedDB for a completed remote stream', async () => {
|
||||||
|
const session: ChatSessionMeta = {
|
||||||
|
sessionId: 'session-b',
|
||||||
|
userId: 'local',
|
||||||
|
characterId: 'default',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 2,
|
||||||
|
}
|
||||||
|
const store = useChatSessionStore()
|
||||||
|
store.applyRemoteSnapshot({
|
||||||
|
activeSessionId: 'session-b',
|
||||||
|
sessionMessages: { 'session-b': [{ id: 'system', role: 'system', content: 'prompt' }] },
|
||||||
|
sessionMetas: { 'session-b': session },
|
||||||
|
index: {
|
||||||
|
userId: 'local',
|
||||||
|
characters: {
|
||||||
|
default: {
|
||||||
|
activeSessionId: 'session-b',
|
||||||
|
sessions: { 'session-b': session },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
getSessionMock.mockResolvedValue({
|
||||||
|
meta: session,
|
||||||
|
messages: [
|
||||||
|
{ id: 'system', role: 'system', content: 'prompt' },
|
||||||
|
{ id: 'assistant', role: 'assistant', content: 'complete answer', slices: [], tool_results: [] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(store.refreshSession('session-b')).resolves.toBe(true)
|
||||||
|
|
||||||
|
expect(getSessionMock).toHaveBeenCalledWith('session-b')
|
||||||
|
expect(store.getSessionMessages('session-b').map(message => message.id)).toEqual(['system', 'assistant'])
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743121862
|
||||||
|
it('moves a follower away from a session removed by another window for Issue #2085', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// Synchronized deletion removed B's metadata, but activeSessionId is
|
||||||
|
// intentionally window-local. A follower that also selected B therefore
|
||||||
|
// kept an invalid selection until it manually chose another session.
|
||||||
|
const sessionA: ChatSessionMeta = {
|
||||||
|
sessionId: 'session-a',
|
||||||
|
userId: 'local',
|
||||||
|
characterId: 'default',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
}
|
||||||
|
const sessionB: ChatSessionMeta = {
|
||||||
|
sessionId: 'session-b',
|
||||||
|
userId: 'local',
|
||||||
|
characterId: 'default',
|
||||||
|
createdAt: 2,
|
||||||
|
updatedAt: 2,
|
||||||
|
}
|
||||||
|
const store = useChatSessionStore()
|
||||||
|
store.applyRemoteSnapshot({
|
||||||
|
activeSessionId: 'session-b',
|
||||||
|
sessionMessages: { 'session-a': [], 'session-b': [] },
|
||||||
|
sessionMetas: { 'session-a': sessionA, 'session-b': sessionB },
|
||||||
|
index: {
|
||||||
|
userId: 'local',
|
||||||
|
characters: {
|
||||||
|
default: {
|
||||||
|
activeSessionId: 'session-a',
|
||||||
|
sessions: { 'session-a': sessionA, 'session-b': sessionB },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
store.applyRemoteSnapshot({
|
||||||
|
activeSessionId: 'session-b',
|
||||||
|
sessionMessages: { 'session-a': [] },
|
||||||
|
sessionMetas: { 'session-a': sessionA },
|
||||||
|
index: {
|
||||||
|
userId: 'local',
|
||||||
|
characters: {
|
||||||
|
default: {
|
||||||
|
activeSessionId: 'session-a',
|
||||||
|
sessions: { 'session-a': sessionA },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.activeSessionId).toBe('session-a')
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743221033
|
||||||
|
it('waits for the leader replacement when every window loses its last session for Issue #2085', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// Every follower independently created a replacement when synchronized
|
||||||
|
// deletion temporarily left no metadata. Multiple windows could therefore
|
||||||
|
// turn one deletion into several empty chats before state converged.
|
||||||
|
const removedSession: ChatSessionMeta = {
|
||||||
|
sessionId: 'session-b',
|
||||||
|
userId: 'local',
|
||||||
|
characterId: 'default',
|
||||||
|
createdAt: 1,
|
||||||
|
updatedAt: 1,
|
||||||
|
}
|
||||||
|
const replacementSession: ChatSessionMeta = {
|
||||||
|
sessionId: 'session-c',
|
||||||
|
userId: 'local',
|
||||||
|
characterId: 'default',
|
||||||
|
createdAt: 2,
|
||||||
|
updatedAt: 2,
|
||||||
|
}
|
||||||
|
const store = useChatSessionStore()
|
||||||
|
store.applyRemoteSnapshot({
|
||||||
|
activeSessionId: 'session-b',
|
||||||
|
sessionMessages: { 'session-b': [] },
|
||||||
|
sessionMetas: { 'session-b': removedSession },
|
||||||
|
index: {
|
||||||
|
userId: 'local',
|
||||||
|
characters: {
|
||||||
|
default: {
|
||||||
|
activeSessionId: 'session-b',
|
||||||
|
sessions: { 'session-b': removedSession },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
saveSessionMock.mockClear()
|
||||||
|
|
||||||
|
store.applyRemoteSnapshot({
|
||||||
|
activeSessionId: 'session-b',
|
||||||
|
sessionMessages: {},
|
||||||
|
sessionMetas: {},
|
||||||
|
index: { userId: 'local', characters: {} },
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(saveSessionMock).not.toHaveBeenCalled()
|
||||||
|
expect(store.activeSessionId).toBe('session-b')
|
||||||
|
|
||||||
|
store.applyRemoteSnapshot({
|
||||||
|
activeSessionId: 'session-b',
|
||||||
|
sessionMessages: { 'session-c': [] },
|
||||||
|
sessionMetas: { 'session-c': replacementSession },
|
||||||
|
index: {
|
||||||
|
userId: 'local',
|
||||||
|
characters: {
|
||||||
|
default: {
|
||||||
|
activeSessionId: 'session-c',
|
||||||
|
sessions: { 'session-c': replacementSession },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.activeSessionId).toBe('session-c')
|
||||||
|
})
|
||||||
|
|
||||||
it('deletes a message by its stable id from the specified session', async () => {
|
it('deletes a message by its stable id from the specified session', async () => {
|
||||||
const store = useChatSessionStore()
|
const store = useChatSessionStore()
|
||||||
store.applyRemoteSnapshot({
|
store.applyRemoteSnapshot({
|
||||||
@@ -415,11 +976,29 @@ describe('chat-session-store · synchronized data actions', () => {
|
|||||||
expect(store.getSessionMessages('session-1').map(message => message.id)).toEqual(['keep'])
|
expect(store.getSessionMessages('session-1').map(message => message.id)).toEqual(['keep'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the active session outside synchronized session state', () => {
|
it('keeps window-local selection out of synchronized and persisted session state', async () => {
|
||||||
const store = useChatSessionStore()
|
const store = useChatSessionStore()
|
||||||
store.activeSessionId = 'window-local-session'
|
store.applyRemoteSnapshot({
|
||||||
|
activeSessionId: 'persisted-session',
|
||||||
|
index: {
|
||||||
|
userId: 'local',
|
||||||
|
characters: {
|
||||||
|
default: {
|
||||||
|
activeSessionId: 'persisted-session',
|
||||||
|
sessions: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sessionMessages: {
|
||||||
|
'window-local-session': [{ id: 'system', role: 'system', content: 'prompt' }],
|
||||||
|
},
|
||||||
|
sessionMetas: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
await store.setActiveSession('window-local-session')
|
||||||
|
|
||||||
expect(store.activeSessionId).toBe('window-local-session')
|
expect(store.activeSessionId).toBe('window-local-session')
|
||||||
expect(store.$state).not.toHaveProperty('activeSessionId')
|
expect(store.$state).not.toHaveProperty('activeSessionId')
|
||||||
|
expect(store.getSnapshot().index?.characters.default?.activeSessionId).toBe('persisted-session')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
const sessionMessages = ref<Record<string, ChatHistoryItem[]>>({})
|
const sessionMessages = ref<Record<string, ChatHistoryItem[]>>({})
|
||||||
const sessionMetas = ref<Record<string, ChatSessionMeta>>({})
|
const sessionMetas = ref<Record<string, ChatSessionMeta>>({})
|
||||||
const sessionGenerations = ref<Record<string, number>>({})
|
const sessionGenerations = ref<Record<string, number>>({})
|
||||||
|
/** Authority-owned session index replicated so each window can derive its local selection. */
|
||||||
const index = ref<ChatSessionsIndex | null>(null)
|
const index = ref<ChatSessionsIndex | null>(null)
|
||||||
|
|
||||||
const ready = ref(false)
|
const ready = ref(false)
|
||||||
@@ -92,7 +93,9 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
|
|
||||||
let persistQueue = Promise.resolve()
|
let persistQueue = Promise.resolve()
|
||||||
const loadedSessions = new Set<string>()
|
const loadedSessions = new Set<string>()
|
||||||
const loadingSessions = new Map<string, Promise<void>>()
|
const staleSessions = new Set<string>()
|
||||||
|
const cloudHydratedSessions = new Set<string>()
|
||||||
|
const loadingSessions = new Map<string, Promise<boolean>>()
|
||||||
|
|
||||||
// Cloud sync state. The WS client is constructed lazily so anonymous
|
// Cloud sync state. The WS client is constructed lazily so anonymous
|
||||||
// (`userId === 'local'`) users never open a socket. `cloudSyncReady` is a
|
// (`userId === 'local'`) users never open a socket. `cloudSyncReady` is a
|
||||||
@@ -110,6 +113,7 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
let cloudMapper: CloudChatMapper | undefined
|
let cloudMapper: CloudChatMapper | undefined
|
||||||
let cloudReconcileTask: Promise<void> | undefined
|
let cloudReconcileTask: Promise<void> | undefined
|
||||||
let pendingReconcile = false
|
let pendingReconcile = false
|
||||||
|
let ownsCloudSync = true
|
||||||
// Incremented on every teardown / user swap. Long-running reconcile IIFEs
|
// Incremented on every teardown / user swap. Long-running reconcile IIFEs
|
||||||
// capture the epoch at start and bail after every await once it changes,
|
// capture the epoch at start and bail after every await once it changes,
|
||||||
// so account-A mutations cannot land on account-B state after a sign-out.
|
// so account-A mutations cannot land on account-B state after a sign-out.
|
||||||
@@ -327,7 +331,10 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Removes one message by stable id or by its current history index. */
|
/** Removes one message by stable id or by its current history index. */
|
||||||
function deleteMessage(payload: DeleteChatMessagePayload) {
|
async function deleteMessage(payload: DeleteChatMessagePayload): Promise<void> {
|
||||||
|
if (!await loadSession(payload.sessionId))
|
||||||
|
throw new Error('Failed to load the target chat session')
|
||||||
|
|
||||||
const nextMessages = getSessionMessages(payload.sessionId).filter((message, messageIndex) => {
|
const nextMessages = getSessionMessages(payload.sessionId).filter((message, messageIndex) => {
|
||||||
if (payload.messageId)
|
if (payload.messageId)
|
||||||
return message.id !== payload.messageId
|
return message.id !== payload.messageId
|
||||||
@@ -351,65 +358,83 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
* - `sessionId` exists either in `sessionMetas` or in IDB.
|
* - `sessionId` exists either in `sessionMetas` or in IDB.
|
||||||
*
|
*
|
||||||
* Returns:
|
* Returns:
|
||||||
* - Resolves once the session is in memory. On IDB error, removes the id
|
* - `true` when the session is in memory, or `false` when hydration failed.
|
||||||
|
* On IDB error, removes the id
|
||||||
* from the loading map so subsequent calls can retry rather than wedge
|
* from the loading map so subsequent calls can retry rather than wedge
|
||||||
* on a stale promise. Errors are intentionally not rethrown — the
|
* on a stale promise. Errors are intentionally not rethrown — the
|
||||||
* failing session is simply absent from local state and the next
|
* failing session is simply absent from local state and the next
|
||||||
* loadSession call will retry.
|
* loadSession call will retry.
|
||||||
*/
|
*/
|
||||||
async function loadSession(sessionId: string) {
|
async function loadSession(sessionId: string): Promise<boolean> {
|
||||||
if (loadedSessions.has(sessionId)) {
|
const needsCloudHydration = () => {
|
||||||
return
|
const meta = sessionMetas.value[sessionId]
|
||||||
|
return !!meta?.cloudChatId && !cloudHydratedSessions.has(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadedSessions.has(sessionId) && !staleSessions.has(sessionId) && !needsCloudHydration()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// A synchronized snapshot already carries the authority's hydrated
|
||||||
|
// messages. Trust it instead of letting this follower merge an older IDB
|
||||||
|
// record and publish that stale full-store proposal back to the leader.
|
||||||
|
if (Object.hasOwn(sessionMessages.value, sessionId) && hasKnownSession(sessionId) && !staleSessions.has(sessionId) && !needsCloudHydration()) {
|
||||||
|
loadedSessions.add(sessionId)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
if (loadingSessions.has(sessionId)) {
|
if (loadingSessions.has(sessionId)) {
|
||||||
await loadingSessions.get(sessionId)
|
return await loadingSessions.get(sessionId)!
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadPromise = (async () => {
|
const loadPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const stored = await chatSessionsRepo.getSession(sessionId)
|
if (!loadedSessions.has(sessionId) || staleSessions.has(sessionId)) {
|
||||||
// Re-check existence: `deleteSession` (or `clearInMemoryState` on a
|
const stored = await chatSessionsRepo.getSession(sessionId)
|
||||||
// user swap) may have run while we were awaiting IDB. Without this
|
// Re-check existence after the IDB read. Deletion or an account
|
||||||
// guard, the post-await write resurrects the deleted entry and
|
// change can remove this session while the read is pending.
|
||||||
// `loadedSessions.add` then short-circuits every future legitimate
|
if (!sessionMetas.value[sessionId])
|
||||||
// load — locking the resurrection in. The drawer's batch
|
return false
|
||||||
// loadSession + per-row trash button hits this race in production.
|
if (staleSessions.has(sessionId) && !stored)
|
||||||
if (!sessionMetas.value[sessionId])
|
return false
|
||||||
return
|
if (stored) {
|
||||||
if (stored) {
|
const currentMessages = sessionMessages.value[sessionId] ?? []
|
||||||
const currentMessages = sessionMessages.value[sessionId] ?? []
|
const mergedMessages = mergeLoadedSessionMessages(stored.messages, currentMessages)
|
||||||
const mergedMessages = mergeLoadedSessionMessages(stored.messages, currentMessages)
|
|
||||||
|
|
||||||
sessionMetas.value[sessionId] = stored.meta
|
sessionMetas.value[sessionId] = stored.meta
|
||||||
replaceSessionMessages(sessionId, mergedMessages, { persist: false })
|
replaceSessionMessages(sessionId, mergedMessages, { persist: false })
|
||||||
ensureGeneration(sessionId)
|
ensureGeneration(sessionId)
|
||||||
|
|
||||||
if (mergedMessages !== stored.messages)
|
if (mergedMessages !== stored.messages)
|
||||||
await persistSession(sessionId)
|
await persistSession(sessionId)
|
||||||
|
}
|
||||||
|
staleSessions.delete(sessionId)
|
||||||
|
loadedSessions.add(sessionId)
|
||||||
|
if (activeSessionId.value === sessionId)
|
||||||
|
refreshActiveSessionSystemMessage()
|
||||||
}
|
}
|
||||||
loadedSessions.add(sessionId)
|
|
||||||
if (activeSessionId.value === sessionId)
|
|
||||||
refreshActiveSessionSystemMessage()
|
|
||||||
|
|
||||||
// Cloud gap fill: when the session is mapped to a cloud chat, ask
|
// Local and cloud hydration are separate. A failed cloud pull leaves
|
||||||
// the server for everything past our highest known seq. Best
|
// the local view usable and keeps the next selection eligible to retry.
|
||||||
// effort — failures are logged inside pullCloudMessages and the
|
if (needsCloudHydration())
|
||||||
// local view stays usable.
|
|
||||||
const meta = sessionMetas.value[sessionId]
|
|
||||||
if (meta?.cloudChatId)
|
|
||||||
await pullCloudMessages(sessionId)
|
await pullCloudMessages(sessionId)
|
||||||
|
|
||||||
|
// Missing IDB payloads still need a valid canonical conversation.
|
||||||
|
// This action runs in the elected leader, so the initialized history
|
||||||
|
// is published once rather than independently by every follower.
|
||||||
|
ensureSession(sessionId)
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
// Do NOT add to loadedSessions on failure — the next call should
|
// Do NOT add to loadedSessions on failure — the next call should
|
||||||
// retry rather than fast-return on stale "already loaded" state.
|
// retry rather than fast-return on stale "already loaded" state.
|
||||||
console.warn('[chat-session] loadSession failed for', sessionId, errorMessageFrom(err))
|
console.warn('[chat-session] loadSession failed for', sessionId, errorMessageFrom(err))
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
loadingSessions.set(sessionId, loadPromise)
|
loadingSessions.set(sessionId, loadPromise)
|
||||||
try {
|
try {
|
||||||
await loadPromise
|
return await loadPromise
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
// Always drain the loading map so a transient failure does not leave
|
// Always drain the loading map so a transient failure does not leave
|
||||||
@@ -418,6 +443,13 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Forces the next session load to merge the latest IndexedDB record into memory. */
|
||||||
|
async function refreshSession(sessionId: string): Promise<boolean> {
|
||||||
|
staleSessions.add(sessionId)
|
||||||
|
loadedSessions.delete(sessionId)
|
||||||
|
return loadSession(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mint a new session for `characterId`, optionally seeding it with messages
|
* Mint a new session for `characterId`, optionally seeding it with messages
|
||||||
* and / or a title. Persists the new session and its index entry, then
|
* and / or a title. Persists the new session and its index entry, then
|
||||||
@@ -493,26 +525,17 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Permanently remove a session from the local index + IDB and, when the
|
* Permanently removes a session locally and tombstones its cloud identity.
|
||||||
* session is cloud-mapped and the user is signed in, soft-delete the
|
|
||||||
* server chat via `DELETE /api/v1/chats/:id`.
|
|
||||||
*
|
*
|
||||||
* Use when:
|
* Cloud failures are logged but do not restore the local session. A
|
||||||
* - The user explicitly chooses "delete" from the sessions drawer.
|
* tombstone prevents the next reconcile from importing it again.
|
||||||
*
|
|
||||||
* Expects:
|
|
||||||
* - The caller does not need to pre-confirm: this method is destructive.
|
|
||||||
* When the deleted session is the active one, the store falls back to
|
|
||||||
* another session for the same character or creates a fresh one.
|
|
||||||
*
|
|
||||||
* Returns:
|
|
||||||
* - Resolves once both local state and (if applicable) the remote DELETE
|
|
||||||
* call have settled. Cloud failures are swallowed with a console.warn —
|
|
||||||
* the local removal goes through either way so the user does not see
|
|
||||||
* a "ghost" session after the click. A tombstone is written so the
|
|
||||||
* reconcile `adopt` branch will not re-import the row on next login.
|
|
||||||
*/
|
*/
|
||||||
async function deleteSession(sessionId: string) {
|
async function deleteSession(sessionId: string) {
|
||||||
|
// Keep a monotonic tombstone in memory so queued and streaming sends that
|
||||||
|
// captured the previous generation cannot become current again after the
|
||||||
|
// session record is removed.
|
||||||
|
bumpSessionGeneration(sessionId)
|
||||||
|
|
||||||
const meta = sessionMetas.value[sessionId]
|
const meta = sessionMetas.value[sessionId]
|
||||||
if (!meta)
|
if (!meta)
|
||||||
return
|
return
|
||||||
@@ -550,8 +573,9 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
// fire-and-forget. Persistence races now read the post-deletion state.
|
// fire-and-forget. Persistence races now read the post-deletion state.
|
||||||
delete sessionMetas.value[sessionId]
|
delete sessionMetas.value[sessionId]
|
||||||
delete sessionMessages.value[sessionId]
|
delete sessionMessages.value[sessionId]
|
||||||
delete sessionGenerations.value[sessionId]
|
|
||||||
loadedSessions.delete(sessionId)
|
loadedSessions.delete(sessionId)
|
||||||
|
staleSessions.delete(sessionId)
|
||||||
|
cloudHydratedSessions.delete(sessionId)
|
||||||
loadingSessions.delete(sessionId)
|
loadingSessions.delete(sessionId)
|
||||||
|
|
||||||
if (index.value) {
|
if (index.value) {
|
||||||
@@ -572,42 +596,55 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
await persistIndex()
|
await persistIndex()
|
||||||
await refreshOutboxPendingCount()
|
await refreshOutboxPendingCount()
|
||||||
|
|
||||||
if (cloudChatId && isCloudUser) {
|
if (isCloudUser) {
|
||||||
|
const remoteChatId = cloudChatId ?? sessionId
|
||||||
// Tombstone first: even if the cloud DELETE never reaches the server
|
// Tombstone first: even if the cloud DELETE never reaches the server
|
||||||
// (offline, transient 5xx), the next reconcile will see the cloudChatId
|
// (offline, transient 5xx), the next reconcile will see the remote id
|
||||||
// here and skip the adopt branch — preventing the ghost-session bug
|
// here and skip the adopt branch — preventing the ghost-session bug
|
||||||
// where the server still has the row and re-creates the local mapping.
|
// where the server still has the row and re-creates the local mapping.
|
||||||
// The reconcile-driven `drainTombstones` retries failed DELETEs.
|
// The reconcile-driven `drainTombstones` retries failed DELETEs.
|
||||||
await enqueuePersist(() => chatSessionsRepo.addTombstone(currentUserId, cloudChatId))
|
await enqueuePersist(() => chatSessionsRepo.addTombstone(currentUserId, remoteChatId))
|
||||||
getCloudMapper().deleteChat(cloudChatId).then(
|
if (cloudChatId) {
|
||||||
async () => {
|
getCloudMapper().deleteChat(cloudChatId).then(
|
||||||
// Server confirmed the delete; reconcile will not see this id again,
|
async () => {
|
||||||
// so we can drop the tombstone.
|
// Server confirmed the delete; reconcile will not see this id again,
|
||||||
await enqueuePersist(() => chatSessionsRepo.removeTombstones(currentUserId, [cloudChatId]))
|
// so we can drop the tombstone.
|
||||||
},
|
await enqueuePersist(() => chatSessionsRepo.removeTombstones(currentUserId, [cloudChatId]))
|
||||||
(err) => {
|
},
|
||||||
console.warn('[chat-sync] DELETE /api/v1/chats failed for', sessionId, errorMessageFrom(err))
|
(err) => {
|
||||||
},
|
console.warn('[chat-sync] DELETE /api/v1/chats failed for', sessionId, errorMessageFrom(err))
|
||||||
)
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the deleted session was active, pick another for the same
|
const characterIndex = index.value?.characters[characterId]
|
||||||
// character or mint a fresh one so the chat surface never lands on an
|
const fallbackId = characterIndex
|
||||||
// empty void.
|
? Object.keys(characterIndex.sessions).find(id => sessionMetas.value[id])
|
||||||
if (wasActive) {
|
: undefined
|
||||||
const characterIndex = index.value?.characters[characterId]
|
|
||||||
const fallbackId = characterIndex
|
// Persisted character fallback is shared, but live selection is local to
|
||||||
? Object.keys(characterIndex.sessions).find(id => sessionMetas.value[id])
|
// the window that was displaying the deleted session.
|
||||||
: undefined
|
if (fallbackId && characterIndex) {
|
||||||
if (fallbackId) {
|
characterIndex.activeSessionId = fallbackId
|
||||||
|
if (wasActive) {
|
||||||
activeSessionId.value = fallbackId
|
activeSessionId.value = fallbackId
|
||||||
if (characterIndex)
|
|
||||||
characterIndex.activeSessionId = fallbackId
|
|
||||||
await loadSession(fallbackId)
|
await loadSession(fallbackId)
|
||||||
await persistIndex()
|
|
||||||
}
|
}
|
||||||
else {
|
await persistIndex()
|
||||||
await createSession(characterId, { setActive: true })
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const replacementSessionId = await createSession(characterId, { setActive: wasActive })
|
||||||
|
if (!wasActive) {
|
||||||
|
// The synchronized leader may be displaying a different character, but
|
||||||
|
// this replacement is still the canonical fallback for the character
|
||||||
|
// whose final session was deleted. Persist that index choice without
|
||||||
|
// navigating the leader's window-local selection.
|
||||||
|
const replacementCharacterIndex = index.value?.characters[characterId]
|
||||||
|
if (replacementCharacterIndex) {
|
||||||
|
replacementCharacterIndex.activeSessionId = replacementSessionId
|
||||||
|
await persistIndex()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -644,10 +681,9 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
activeSessionId.value = characterIndex.activeSessionId
|
activeSessionId.value = characterIndex.activeSessionId
|
||||||
await loadSession(characterIndex.activeSessionId)
|
// Use the public action so follower hydration is routed to the elected
|
||||||
if (isStaleEpoch())
|
// leader instead of becoming a stale full-state proposal.
|
||||||
return
|
await useChatSessionStore().loadSession(characterIndex.activeSessionId)
|
||||||
ensureSession(characterIndex.activeSessionId)
|
|
||||||
})()
|
})()
|
||||||
try {
|
try {
|
||||||
await ensureActivePromise
|
await ensureActivePromise
|
||||||
@@ -719,6 +755,7 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
messages: result.messages,
|
messages: result.messages,
|
||||||
toSeq: result.seq,
|
toSeq: result.seq,
|
||||||
})
|
})
|
||||||
|
cloudHydratedSessions.add(sessionId)
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.warn('[chat-sync] pullMessages failed for', sessionId, errorMessageFrom(err))
|
console.warn('[chat-sync] pullMessages failed for', sessionId, errorMessageFrom(err))
|
||||||
@@ -742,6 +779,8 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
* pass is scheduled in `finally` so catch-up pulls do not get lost.
|
* pass is scheduled in `finally` so catch-up pulls do not get lost.
|
||||||
*/
|
*/
|
||||||
async function reconcileCloudSessions(): Promise<void> {
|
async function reconcileCloudSessions(): Promise<void> {
|
||||||
|
if (!ownsCloudSync)
|
||||||
|
return
|
||||||
if (cloudReconcileTask) {
|
if (cloudReconcileTask) {
|
||||||
pendingReconcile = true
|
pendingReconcile = true
|
||||||
return cloudReconcileTask
|
return cloudReconcileTask
|
||||||
@@ -750,7 +789,7 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
const myEpoch = reconcileEpoch
|
const myEpoch = reconcileEpoch
|
||||||
const isStaleEpoch = () => myEpoch !== reconcileEpoch
|
const isStaleEpoch = () => myEpoch !== reconcileEpoch
|
||||||
|
|
||||||
cloudReconcileTask = (async () => {
|
const reconcileTask = (async () => {
|
||||||
const currentUserId = getCurrentUserId()
|
const currentUserId = getCurrentUserId()
|
||||||
if (currentUserId === 'local') {
|
if (currentUserId === 'local') {
|
||||||
console.info('[chat-sync] reconcile skipped: anonymous user')
|
console.info('[chat-sync] reconcile skipped: anonymous user')
|
||||||
@@ -907,6 +946,8 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
|
|
||||||
cloudSyncReady.value = true
|
cloudSyncReady.value = true
|
||||||
})().finally(() => {
|
})().finally(() => {
|
||||||
|
if (cloudReconcileTask !== reconcileTask)
|
||||||
|
return
|
||||||
cloudReconcileTask = undefined
|
cloudReconcileTask = undefined
|
||||||
// A second 'open' event fired while we were running — schedule a
|
// A second 'open' event fired while we were running — schedule a
|
||||||
// follow-up so its catch-up window is not lost. Skip if the epoch
|
// follow-up so its catch-up window is not lost. Skip if the epoch
|
||||||
@@ -920,7 +961,8 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return cloudReconcileTask
|
cloudReconcileTask = reconcileTask
|
||||||
|
return reconcileTask
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -929,6 +971,8 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
* from the auth `watch`.
|
* from the auth `watch`.
|
||||||
*/
|
*/
|
||||||
function ensureCloudWsClient() {
|
function ensureCloudWsClient() {
|
||||||
|
if (!ownsCloudSync)
|
||||||
|
return
|
||||||
if (getCurrentUserId() === 'local') {
|
if (getCurrentUserId() === 'local') {
|
||||||
console.info('[chat-sync] WS skipped: anonymous user')
|
console.info('[chat-sync] WS skipped: anonymous user')
|
||||||
return
|
return
|
||||||
@@ -985,6 +1029,20 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
cloudMapper = undefined
|
cloudMapper = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Starts or stops cloud synchronization when this window gains or loses synchronized-store leadership. */
|
||||||
|
function setCloudSyncOwnership(owns: boolean) {
|
||||||
|
if (ownsCloudSync === owns)
|
||||||
|
return
|
||||||
|
|
||||||
|
ownsCloudSync = owns
|
||||||
|
if (!ready.value)
|
||||||
|
return
|
||||||
|
if (owns)
|
||||||
|
ensureCloudWsClient()
|
||||||
|
else if (wsClient)
|
||||||
|
teardownCloudWsClient()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drop every in-memory session for the current user. Used when the auth
|
* Drop every in-memory session for the current user. Used when the auth
|
||||||
* user changes — we must NOT keep account A's sessions visible (or
|
* user changes — we must NOT keep account A's sessions visible (or
|
||||||
@@ -1002,6 +1060,8 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
sessionMetas.value = {}
|
sessionMetas.value = {}
|
||||||
sessionGenerations.value = {}
|
sessionGenerations.value = {}
|
||||||
loadedSessions.clear()
|
loadedSessions.clear()
|
||||||
|
staleSessions.clear()
|
||||||
|
cloudHydratedSessions.clear()
|
||||||
loadingSessions.clear()
|
loadingSessions.clear()
|
||||||
index.value = null
|
index.value = null
|
||||||
activeSessionId.value = ''
|
activeSessionId.value = ''
|
||||||
@@ -1231,7 +1291,8 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
// mid-send) before the WS even opens. The drain itself runs after
|
// mid-send) before the WS even opens. The drain itself runs after
|
||||||
// reconcile completes, but the count is observable immediately.
|
// reconcile completes, but the count is observable immediately.
|
||||||
await refreshOutboxPendingCount()
|
await refreshOutboxPendingCount()
|
||||||
ensureCloudWsClient()
|
if (ownsCloudSync)
|
||||||
|
ensureCloudWsClient()
|
||||||
})()
|
})()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1272,22 +1333,14 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function setActiveSession(sessionId: string) {
|
/** Selects and hydrates one conversation only in the current window. */
|
||||||
|
async function setActiveSession(sessionId: string) {
|
||||||
activeSessionId.value = sessionId
|
activeSessionId.value = sessionId
|
||||||
|
|
||||||
const characterId = getCurrentCharacterId()
|
if (ready.value)
|
||||||
const characterIndex = index.value?.characters[characterId]
|
await useChatSessionStore().loadSession(sessionId)
|
||||||
if (characterIndex) {
|
else if (!hasKnownSession(sessionId))
|
||||||
characterIndex.activeSessionId = sessionId
|
|
||||||
void persistIndex()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ready.value) {
|
|
||||||
void loadSession(sessionId)
|
|
||||||
}
|
|
||||||
else if (!hasKnownSession(sessionId)) {
|
|
||||||
ensureSession(sessionId)
|
ensureSession(sessionId)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyRemoteSnapshot(snapshot: {
|
function applyRemoteSnapshot(snapshot: {
|
||||||
@@ -1306,6 +1359,8 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
Object.keys(snapshot.sessionMessages).map(sessionId => [sessionId, sessionGenerations.value[sessionId] ?? 0]),
|
Object.keys(snapshot.sessionMessages).map(sessionId => [sessionId, sessionGenerations.value[sessionId] ?? 0]),
|
||||||
)
|
)
|
||||||
loadedSessions.clear()
|
loadedSessions.clear()
|
||||||
|
staleSessions.clear()
|
||||||
|
cloudHydratedSessions.clear()
|
||||||
for (const sessionId of Object.keys(snapshot.sessionMessages)) {
|
for (const sessionId of Object.keys(snapshot.sessionMessages)) {
|
||||||
loadedSessions.add(sessionId)
|
loadedSessions.add(sessionId)
|
||||||
}
|
}
|
||||||
@@ -1349,6 +1404,8 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
sessionMetas.value = {}
|
sessionMetas.value = {}
|
||||||
sessionGenerations.value = {}
|
sessionGenerations.value = {}
|
||||||
loadedSessions.clear()
|
loadedSessions.clear()
|
||||||
|
staleSessions.clear()
|
||||||
|
cloudHydratedSessions.clear()
|
||||||
loadingSessions.clear()
|
loadingSessions.clear()
|
||||||
|
|
||||||
index.value = {
|
index.value = {
|
||||||
@@ -1364,6 +1421,11 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
return sessionMessages.value[sessionId] ?? []
|
return sessionMessages.value[sessionId] ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns persisted/in-memory messages without creating an unloaded session fallback. */
|
||||||
|
function getSessionMessagesIfLoaded(sessionId: string) {
|
||||||
|
return sessionMessages.value[sessionId]
|
||||||
|
}
|
||||||
|
|
||||||
function getSessionGeneration(sessionId: string) {
|
function getSessionGeneration(sessionId: string) {
|
||||||
ensureGeneration(sessionId)
|
ensureGeneration(sessionId)
|
||||||
return sessionGenerations.value[sessionId] ?? 0
|
return sessionGenerations.value[sessionId] ?? 0
|
||||||
@@ -1432,6 +1494,8 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
sessionMetas.value = {}
|
sessionMetas.value = {}
|
||||||
sessionGenerations.value = {}
|
sessionGenerations.value = {}
|
||||||
loadedSessions.clear()
|
loadedSessions.clear()
|
||||||
|
staleSessions.clear()
|
||||||
|
cloudHydratedSessions.clear()
|
||||||
loadingSessions.clear()
|
loadingSessions.clear()
|
||||||
|
|
||||||
await enqueuePersist(() => chatSessionsRepo.saveIndex(cloneDeep(payload.index)))
|
await enqueuePersist(() => chatSessionsRepo.saveIndex(cloneDeep(payload.index)))
|
||||||
@@ -1449,6 +1513,39 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
await ensureActiveSessionForCharacter()
|
await ensureActiveSessionForCharacter()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lastActiveSessionMeta: ChatSessionMeta | undefined
|
||||||
|
|
||||||
|
// Session data is synchronized, but selection belongs to this window. If
|
||||||
|
// another window deletes the selected session, repair this window locally
|
||||||
|
// instead of leaving it pointed at an ID that can no longer hydrate.
|
||||||
|
watch([
|
||||||
|
activeSessionId,
|
||||||
|
() => Object.values(sessionMetas.value),
|
||||||
|
() => hasKnownSession(activeSessionId.value),
|
||||||
|
], ([sessionId, metas, isKnown]) => {
|
||||||
|
const meta = metas.find(candidate => candidate.sessionId === sessionId)
|
||||||
|
if (meta) {
|
||||||
|
lastActiveSessionMeta = meta
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!sessionId || isKnown)
|
||||||
|
return
|
||||||
|
|
||||||
|
const characterId = lastActiveSessionMeta?.sessionId === sessionId
|
||||||
|
? lastActiveSessionMeta.characterId
|
||||||
|
: getCurrentCharacterId()
|
||||||
|
const fallbackSessionId = metas
|
||||||
|
.find(candidate => candidate.characterId === characterId && candidate.userId === getCurrentUserId())
|
||||||
|
?.sessionId
|
||||||
|
|
||||||
|
if (fallbackSessionId) {
|
||||||
|
void setActiveSession(fallbackSessionId)
|
||||||
|
}
|
||||||
|
// If no fallback exists yet, wait for the synchronized delete action.
|
||||||
|
// Its elected leader creates the single replacement; creating here in
|
||||||
|
// every follower would fan one deletion out into several empty chats.
|
||||||
|
})
|
||||||
|
|
||||||
watch([userId, activeCardId], () => {
|
watch([userId, activeCardId], () => {
|
||||||
if (!ready.value)
|
if (!ready.value)
|
||||||
return
|
return
|
||||||
@@ -1470,7 +1567,7 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
watch(userId, (next) => {
|
watch(userId, (next) => {
|
||||||
teardownCloudWsClient()
|
teardownCloudWsClient()
|
||||||
clearInMemoryState()
|
clearInMemoryState()
|
||||||
if (next && next !== 'local') {
|
if (ownsCloudSync && next && next !== 'local') {
|
||||||
ensureCloudWsClient()
|
ensureCloudWsClient()
|
||||||
}
|
}
|
||||||
// Rehydrate for the new user. We trigger here (instead of relying on the
|
// Rehydrate for the new user. We trigger here (instead of relying on the
|
||||||
@@ -1485,7 +1582,6 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
|
||||||
isReady,
|
isReady,
|
||||||
initialize,
|
initialize,
|
||||||
|
|
||||||
@@ -1505,26 +1601,32 @@ export const useChatSessionStore = defineStore('chat-session', () => {
|
|||||||
appendSessionMessage,
|
appendSessionMessage,
|
||||||
persistSessionMessages,
|
persistSessionMessages,
|
||||||
getSessionMessages,
|
getSessionMessages,
|
||||||
|
getSessionMessagesIfLoaded,
|
||||||
sessionMessages,
|
sessionMessages,
|
||||||
sessionMetas,
|
sessionMetas,
|
||||||
getSessionGeneration,
|
getSessionGeneration,
|
||||||
bumpSessionGeneration,
|
bumpSessionGeneration,
|
||||||
getSessionGenerationValue,
|
getSessionGenerationValue,
|
||||||
|
// Pinia can synchronize only refs returned by a setup store.
|
||||||
|
index,
|
||||||
|
|
||||||
forkSession,
|
forkSession,
|
||||||
exportSessions,
|
exportSessions,
|
||||||
importSessions,
|
importSessions,
|
||||||
createSession,
|
createSession,
|
||||||
loadSession,
|
loadSession,
|
||||||
|
refreshSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
|
|
||||||
|
setCloudSyncOwnership,
|
||||||
|
|
||||||
cloudSyncReady,
|
cloudSyncReady,
|
||||||
outboxPendingCount,
|
outboxPendingCount,
|
||||||
pushMessageToCloud,
|
pushMessageToCloud,
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
synced: {
|
synced: {
|
||||||
actions: ['deleteMessage', 'importSessions'],
|
actions: ['createSession', 'deleteMessage', 'importSessions', 'loadSession', 'refreshSession'],
|
||||||
state: true,
|
state: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const beginStreamMock = vi.fn()
|
|||||||
const appendStreamLiteralMock = vi.fn()
|
const appendStreamLiteralMock = vi.fn()
|
||||||
const finalizeStreamMock = vi.fn()
|
const finalizeStreamMock = vi.fn()
|
||||||
const resetStreamMock = vi.fn()
|
const resetStreamMock = vi.fn()
|
||||||
|
const refreshSessionMock = vi.fn()
|
||||||
const serverSendMock = vi.fn()
|
const serverSendMock = vi.fn()
|
||||||
const ensureConnectedMock = vi.fn().mockResolvedValue(undefined)
|
const ensureConnectedMock = vi.fn().mockResolvedValue(undefined)
|
||||||
const onReconnectedMock = vi.fn(() => () => {})
|
const onReconnectedMock = vi.fn(() => () => {})
|
||||||
@@ -157,6 +158,7 @@ function createContextUpdateEvent(overrides: Record<string, unknown> = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chatOrchestratorMock = {
|
const chatOrchestratorMock = {
|
||||||
|
activeSendSessionId: undefined as string | undefined,
|
||||||
sending: false,
|
sending: false,
|
||||||
ingest: vi.fn(),
|
ingest: vi.fn(),
|
||||||
|
|
||||||
@@ -237,6 +239,7 @@ vi.mock('../../chat/session-store', () => ({
|
|||||||
return activeSessionIdRef.value
|
return activeSessionIdRef.value
|
||||||
},
|
},
|
||||||
getSessionGenerationValue: () => currentGeneration,
|
getSessionGenerationValue: () => currentGeneration,
|
||||||
|
refreshSession: (sessionId: string) => refreshSessionMock(sessionId),
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -291,6 +294,7 @@ describe('context bridge contract', () => {
|
|||||||
appendStreamLiteralMock.mockReset()
|
appendStreamLiteralMock.mockReset()
|
||||||
finalizeStreamMock.mockReset()
|
finalizeStreamMock.mockReset()
|
||||||
resetStreamMock.mockReset()
|
resetStreamMock.mockReset()
|
||||||
|
refreshSessionMock.mockReset().mockResolvedValue(true)
|
||||||
serverSendMock.mockReset()
|
serverSendMock.mockReset()
|
||||||
ensureConnectedMock.mockClear()
|
ensureConnectedMock.mockClear()
|
||||||
ensureConnectedMock.mockResolvedValue(undefined)
|
ensureConnectedMock.mockResolvedValue(undefined)
|
||||||
@@ -304,6 +308,7 @@ describe('context bridge contract', () => {
|
|||||||
activeProviderRef.value = null
|
activeProviderRef.value = null
|
||||||
activeModelRef.value = null
|
activeModelRef.value = null
|
||||||
activeSessionIdRef.value = 'session-1'
|
activeSessionIdRef.value = 'session-1'
|
||||||
|
chatOrchestratorMock.activeSendSessionId = undefined
|
||||||
currentGeneration = 7
|
currentGeneration = 7
|
||||||
chatOrchestratorMock.sending = false
|
chatOrchestratorMock.sending = false
|
||||||
|
|
||||||
@@ -325,10 +330,6 @@ describe('context bridge contract', () => {
|
|||||||
closeTestChannels()
|
closeTestChannels()
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* @example
|
|
||||||
* Broadcast context updates record store-ingested with core result fields.
|
|
||||||
*/
|
|
||||||
it('records core ingest result for broadcast context updates', async () => {
|
it('records core ingest result for broadcast context updates', async () => {
|
||||||
chatContextIngestMock.mockReturnValueOnce({
|
chatContextIngestMock.mockReturnValueOnce({
|
||||||
sourceKey: 'weather:station-1',
|
sourceKey: 'weather:station-1',
|
||||||
@@ -361,10 +362,6 @@ describe('context bridge contract', () => {
|
|||||||
await store.dispose()
|
await store.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* @example
|
|
||||||
* Server context updates record store-ingested before broadcast-posted.
|
|
||||||
*/
|
|
||||||
it('records core ingest result for server context updates before broadcasting', async () => {
|
it('records core ingest result for server context updates before broadcasting', async () => {
|
||||||
chatContextIngestMock.mockReturnValueOnce({
|
chatContextIngestMock.mockReturnValueOnce({
|
||||||
sourceKey: 'weather:station-1',
|
sourceKey: 'weather:station-1',
|
||||||
@@ -399,10 +396,6 @@ describe('context bridge contract', () => {
|
|||||||
await store.dispose()
|
await store.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* @example
|
|
||||||
* Input context updates record store-ingested and stay in chat input payload.
|
|
||||||
*/
|
|
||||||
it('records core ingest result for input context updates and forwards accepted updates', async () => {
|
it('records core ingest result for input context updates and forwards accepted updates', async () => {
|
||||||
chatContextIngestMock.mockReturnValueOnce({
|
chatContextIngestMock.mockReturnValueOnce({
|
||||||
sourceKey: 'weather:station-1',
|
sourceKey: 'weather:station-1',
|
||||||
@@ -453,10 +446,6 @@ describe('context bridge contract', () => {
|
|||||||
await store.dispose()
|
await store.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* @example
|
|
||||||
* Broadcast context ingest failures record store-ingest-rejected instead of escaping.
|
|
||||||
*/
|
|
||||||
it('records rejected lifecycle for broadcast ingest failures without interrupting the watcher', async () => {
|
it('records rejected lifecycle for broadcast ingest failures without interrupting the watcher', async () => {
|
||||||
chatContextIngestMock.mockImplementationOnce(() => {
|
chatContextIngestMock.mockImplementationOnce(() => {
|
||||||
throw new Error('Cannot clone broadcast context')
|
throw new Error('Cannot clone broadcast context')
|
||||||
@@ -485,10 +474,6 @@ describe('context bridge contract', () => {
|
|||||||
await store.dispose()
|
await store.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* @example
|
|
||||||
* Server context ingest failures are not rebroadcast.
|
|
||||||
*/
|
|
||||||
it('records rejected lifecycle and skips broadcast when server context ingest fails', async () => {
|
it('records rejected lifecycle and skips broadcast when server context ingest fails', async () => {
|
||||||
chatContextIngestMock.mockImplementationOnce(() => {
|
chatContextIngestMock.mockImplementationOnce(() => {
|
||||||
throw new Error('Cannot clone server context')
|
throw new Error('Cannot clone server context')
|
||||||
@@ -520,10 +505,6 @@ describe('context bridge contract', () => {
|
|||||||
await store.dispose()
|
await store.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* @example
|
|
||||||
* Input context ingest failures drop only the failed context update.
|
|
||||||
*/
|
|
||||||
it('records rejected lifecycle and continues text ingestion when input context ingest fails', async () => {
|
it('records rejected lifecycle and continues text ingestion when input context ingest fails', async () => {
|
||||||
chatContextIngestMock.mockImplementationOnce(() => {
|
chatContextIngestMock.mockImplementationOnce(() => {
|
||||||
throw new Error('Cannot clone input context')
|
throw new Error('Cannot clone input context')
|
||||||
@@ -562,7 +543,14 @@ describe('context bridge contract', () => {
|
|||||||
await store.dispose()
|
await store.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('replays remote stream lifecycle into sending and stream store APIs', async () => {
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3743366445
|
||||||
|
it('keeps a remote stream visible locally without publishing chat authority state for Issue #2085', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// Stage Pocket uses plain Pinia, so remote stream tokens update only the
|
||||||
|
// local stream store. The history requires a sending flag, but writing
|
||||||
|
// that flag to the synchronized chat store would let a follower overwrite
|
||||||
|
// the elected authority's stream snapshot.
|
||||||
const store = useContextBridgeStore()
|
const store = useContextBridgeStore()
|
||||||
await store.initialize()
|
await store.initialize()
|
||||||
const streamSender = createTestChannel(CHAT_STREAM_CHANNEL_NAME)
|
const streamSender = createTestChannel(CHAT_STREAM_CHANNEL_NAME)
|
||||||
@@ -574,18 +562,19 @@ describe('context bridge contract', () => {
|
|||||||
composedMessage: [],
|
composedMessage: [],
|
||||||
} satisfies ChatStreamEventContext
|
} satisfies ChatStreamEventContext
|
||||||
|
|
||||||
streamSender.postMessage({ type: 'before-send', message: 'ping', sessionId: 'remote-session', context })
|
streamSender.postMessage({ type: 'before-send', message: 'ping', sessionId: 'session-1', context })
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(chatOrchestratorMock.sending).toBe(true)
|
|
||||||
expect(beginStreamMock).toHaveBeenCalledWith('turn-1')
|
expect(beginStreamMock).toHaveBeenCalledWith('turn-1')
|
||||||
})
|
})
|
||||||
|
expect(chatOrchestratorMock.sending).toBe(false)
|
||||||
|
expect(store.isReceivingRemoteStream).toBe(true)
|
||||||
|
|
||||||
streamSender.postMessage({ type: 'token-literal', literal: 'hello', sessionId: 'remote-session', context })
|
streamSender.postMessage({ type: 'token-literal', literal: 'hello', sessionId: 'session-1', context })
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(appendStreamLiteralMock).toHaveBeenCalledWith('hello')
|
expect(appendStreamLiteralMock).toHaveBeenCalledWith('hello')
|
||||||
})
|
})
|
||||||
|
|
||||||
streamSender.postMessage({ type: 'assistant-end', message: 'final answer', sessionId: 'remote-session', context })
|
streamSender.postMessage({ type: 'assistant-end', message: 'final answer', sessionId: 'session-1', context })
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(resetStreamMock).toHaveBeenCalledTimes(1)
|
expect(resetStreamMock).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
@@ -594,6 +583,7 @@ describe('context bridge contract', () => {
|
|||||||
// to avoid corrupting history by persisting a duplicate assistant message.
|
// to avoid corrupting history by persisting a duplicate assistant message.
|
||||||
expect(finalizeStreamMock).not.toHaveBeenCalled()
|
expect(finalizeStreamMock).not.toHaveBeenCalled()
|
||||||
expect(chatOrchestratorMock.sending).toBe(false)
|
expect(chatOrchestratorMock.sending).toBe(false)
|
||||||
|
expect(store.isReceivingRemoteStream).toBe(false)
|
||||||
|
|
||||||
await store.dispose()
|
await store.dispose()
|
||||||
})
|
})
|
||||||
@@ -624,6 +614,166 @@ describe('context bridge contract', () => {
|
|||||||
await store.dispose()
|
await store.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('labels outbound stream events with the session that owns the send', async () => {
|
||||||
|
const outgoingStreamMessages = collectChannelMessages<{ sessionId: string }>(CHAT_STREAM_CHANNEL_NAME)
|
||||||
|
const store = useContextBridgeStore()
|
||||||
|
await store.initialize()
|
||||||
|
const context = {
|
||||||
|
turnId: 'turn-1',
|
||||||
|
message: { role: 'user', content: 'ping' },
|
||||||
|
contexts: {},
|
||||||
|
composedMessage: [],
|
||||||
|
} satisfies ChatStreamEventContext
|
||||||
|
|
||||||
|
chatOrchestratorMock.activeSendSessionId = 'session-a'
|
||||||
|
activeSessionIdRef.value = 'session-b'
|
||||||
|
await chatOrchestratorMock.emitTokenLiteralHooks('session A token', context)
|
||||||
|
await vi.waitFor(() => expect(outgoingStreamMessages).toHaveLength(1))
|
||||||
|
|
||||||
|
expect(outgoingStreamMessages[0]?.sessionId).toBe('session-a')
|
||||||
|
await store.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears remote stream visibility when an end hook rejects', async () => {
|
||||||
|
const store = useContextBridgeStore()
|
||||||
|
await store.initialize()
|
||||||
|
const streamSender = createTestChannel(CHAT_STREAM_CHANNEL_NAME)
|
||||||
|
const context = {
|
||||||
|
turnId: 'turn-1',
|
||||||
|
message: { role: 'user', content: 'ping' },
|
||||||
|
contexts: {},
|
||||||
|
composedMessage: [],
|
||||||
|
} satisfies ChatStreamEventContext
|
||||||
|
chatOrchestratorMock.onStreamEnd(async () => {
|
||||||
|
throw new Error('end hook failed')
|
||||||
|
})
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
|
||||||
|
streamSender.postMessage({ type: 'before-send', message: 'ping', sessionId: 'session-1', context })
|
||||||
|
await vi.waitFor(() => expect(store.isReceivingRemoteStream).toBe(true))
|
||||||
|
streamSender.postMessage({ type: 'stream-end', sessionId: 'session-1', context })
|
||||||
|
await vi.waitFor(() => expect(store.isReceivingRemoteStream).toBe(false))
|
||||||
|
|
||||||
|
expect(resetStreamMock).toHaveBeenCalledTimes(1)
|
||||||
|
await store.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores stream events that do not match the active remote session', async () => {
|
||||||
|
const store = useContextBridgeStore()
|
||||||
|
await store.initialize()
|
||||||
|
const streamSender = createTestChannel(CHAT_STREAM_CHANNEL_NAME)
|
||||||
|
const context = {
|
||||||
|
turnId: 'turn-1',
|
||||||
|
message: { role: 'user', content: 'ping' },
|
||||||
|
contexts: {},
|
||||||
|
composedMessage: [],
|
||||||
|
} satisfies ChatStreamEventContext
|
||||||
|
|
||||||
|
streamSender.postMessage({ type: 'before-send', message: 'ping', sessionId: 'session-1', context })
|
||||||
|
await vi.waitFor(() => expect(store.isReceivingRemoteStream).toBe(true))
|
||||||
|
streamSender.postMessage({ type: 'token-literal', literal: 'foreign token', sessionId: 'session-2', context })
|
||||||
|
streamSender.postMessage({ type: 'stream-end', sessionId: 'session-2', context })
|
||||||
|
await waitForBroadcastDelivery()
|
||||||
|
|
||||||
|
expect(appendStreamLiteralMock).not.toHaveBeenCalledWith('foreign token')
|
||||||
|
expect(store.isReceivingRemoteStream).toBe(true)
|
||||||
|
streamSender.postMessage({ type: 'stream-end', sessionId: 'session-1', context })
|
||||||
|
await vi.waitFor(() => expect(store.isReceivingRemoteStream).toBe(false))
|
||||||
|
await store.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not replace the foreground stream for an inactive remote session', async () => {
|
||||||
|
const store = useContextBridgeStore()
|
||||||
|
await store.initialize()
|
||||||
|
const streamSender = createTestChannel(CHAT_STREAM_CHANNEL_NAME)
|
||||||
|
const context = {
|
||||||
|
turnId: 'turn-2',
|
||||||
|
message: { role: 'user', content: 'background ping' },
|
||||||
|
contexts: {},
|
||||||
|
composedMessage: [],
|
||||||
|
} satisfies ChatStreamEventContext
|
||||||
|
|
||||||
|
streamSender.postMessage({ type: 'before-send', message: 'background ping', sessionId: 'session-2', context })
|
||||||
|
await waitForBroadcastDelivery()
|
||||||
|
expect(beginStreamMock).not.toHaveBeenCalled()
|
||||||
|
expect(store.isReceivingRemoteStream).toBe(false)
|
||||||
|
|
||||||
|
streamSender.postMessage({ type: 'stream-end', sessionId: 'session-2', context })
|
||||||
|
await waitForBroadcastDelivery()
|
||||||
|
expect(resetStreamMock).not.toHaveBeenCalled()
|
||||||
|
await store.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3755585351
|
||||||
|
it('keeps remote literals received before a mid-stream session switch for Issue #2085', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// The bridge discarded literals while their session was not selected.
|
||||||
|
// Selecting that session during the stream showed only later literals.
|
||||||
|
activeSessionIdRef.value = 'session-2'
|
||||||
|
const store = useContextBridgeStore()
|
||||||
|
await store.initialize()
|
||||||
|
const streamSender = createTestChannel(CHAT_STREAM_CHANNEL_NAME)
|
||||||
|
const context = {
|
||||||
|
turnId: 'turn-3',
|
||||||
|
message: { role: 'user', content: 'background ping' },
|
||||||
|
contexts: {},
|
||||||
|
composedMessage: [],
|
||||||
|
} satisfies ChatStreamEventContext
|
||||||
|
|
||||||
|
streamSender.postMessage({ type: 'before-send', message: 'background ping', sessionId: 'session-1', context })
|
||||||
|
streamSender.postMessage({ type: 'token-literal', literal: 'first half ', sessionId: 'session-1', context })
|
||||||
|
await waitForBroadcastDelivery()
|
||||||
|
expect(beginStreamMock).not.toHaveBeenCalled()
|
||||||
|
expect(appendStreamLiteralMock).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
activeSessionIdRef.value = 'session-1'
|
||||||
|
streamSender.postMessage({ type: 'token-literal', literal: 'second half', sessionId: 'session-1', context })
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(appendStreamLiteralMock).toHaveBeenCalledTimes(2))
|
||||||
|
expect(beginStreamMock).toHaveBeenCalledWith('turn-3')
|
||||||
|
expect(appendStreamLiteralMock).toHaveBeenNthCalledWith(1, 'first half ')
|
||||||
|
expect(appendStreamLiteralMock).toHaveBeenNthCalledWith(2, 'second half')
|
||||||
|
|
||||||
|
await store.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3755711154
|
||||||
|
it('reloads a completed remote stream when its Pocket session becomes active for Issue #2085', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// A plain-Pinia Pocket tab discarded a completed background stream.
|
||||||
|
// Its loaded-session cache then prevented a later IndexedDB refresh.
|
||||||
|
activeSessionIdRef.value = 'session-1'
|
||||||
|
const store = useContextBridgeStore()
|
||||||
|
await store.initialize()
|
||||||
|
const streamSender = createTestChannel(CHAT_STREAM_CHANNEL_NAME)
|
||||||
|
const context = {
|
||||||
|
turnId: 'turn-4',
|
||||||
|
message: { role: 'user', content: 'background ping' },
|
||||||
|
contexts: {},
|
||||||
|
composedMessage: [],
|
||||||
|
} satisfies ChatStreamEventContext
|
||||||
|
|
||||||
|
streamSender.postMessage({ type: 'before-send', message: 'background ping', sessionId: 'session-2', context })
|
||||||
|
streamSender.postMessage({ type: 'token-literal', literal: 'complete answer', sessionId: 'session-2', context })
|
||||||
|
streamSender.postMessage({ type: 'stream-end', sessionId: 'session-2', context })
|
||||||
|
streamSender.postMessage({ type: 'assistant-end', message: 'complete answer', sessionId: 'session-2', context })
|
||||||
|
await waitForBroadcastDelivery()
|
||||||
|
|
||||||
|
expect(refreshSessionMock).not.toHaveBeenCalled()
|
||||||
|
expect(resetStreamMock).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
activeSessionIdRef.value = 'session-2'
|
||||||
|
await vi.waitFor(() => expect(refreshSessionMock).toHaveBeenCalledWith('session-2'))
|
||||||
|
await vi.waitFor(() => expect(resetStreamMock).toHaveBeenCalledTimes(1))
|
||||||
|
expect(beginStreamMock).toHaveBeenCalledWith('turn-4')
|
||||||
|
expect(appendStreamLiteralMock).toHaveBeenCalledWith('complete answer')
|
||||||
|
expect(store.isReceivingRemoteStream).toBe(false)
|
||||||
|
|
||||||
|
await store.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
it('ignores remote literal and end events when generation guard is stale', async () => {
|
it('ignores remote literal and end events when generation guard is stale', async () => {
|
||||||
const store = useContextBridgeStore()
|
const store = useContextBridgeStore()
|
||||||
await store.initialize()
|
await store.initialize()
|
||||||
@@ -636,21 +786,22 @@ describe('context bridge contract', () => {
|
|||||||
composedMessage: [],
|
composedMessage: [],
|
||||||
} satisfies ChatStreamEventContext
|
} satisfies ChatStreamEventContext
|
||||||
|
|
||||||
streamSender.postMessage({ type: 'before-send', message: 'ping', sessionId: 'remote-session', context })
|
streamSender.postMessage({ type: 'before-send', message: 'ping', sessionId: 'session-1', context })
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(beginStreamMock).toHaveBeenCalledWith('turn-1')
|
expect(beginStreamMock).toHaveBeenCalledWith('turn-1')
|
||||||
})
|
})
|
||||||
|
|
||||||
currentGeneration = 8
|
currentGeneration = 8
|
||||||
streamSender.postMessage({ type: 'token-literal', literal: 'stale-literal', sessionId: 'remote-session', context })
|
streamSender.postMessage({ type: 'token-literal', literal: 'stale-literal', sessionId: 'session-1', context })
|
||||||
await waitForBroadcastDelivery()
|
await waitForBroadcastDelivery()
|
||||||
|
|
||||||
streamSender.postMessage({ type: 'stream-end', sessionId: 'remote-session', context })
|
streamSender.postMessage({ type: 'stream-end', sessionId: 'session-1', context })
|
||||||
await waitForBroadcastDelivery()
|
await waitForBroadcastDelivery()
|
||||||
|
|
||||||
expect(appendStreamLiteralMock).not.toHaveBeenCalledWith('stale-literal')
|
expect(appendStreamLiteralMock).not.toHaveBeenCalledWith('stale-literal')
|
||||||
expect(finalizeStreamMock).not.toHaveBeenCalled()
|
expect(finalizeStreamMock).not.toHaveBeenCalled()
|
||||||
expect(chatOrchestratorMock.sending).toBe(true)
|
expect(chatOrchestratorMock.sending).toBe(false)
|
||||||
|
expect(store.isReceivingRemoteStream).toBe(false)
|
||||||
|
|
||||||
await store.dispose()
|
await store.dispose()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useBroadcastChannel } from '@vueuse/core'
|
|||||||
import { Mutex } from 'es-toolkit'
|
import { Mutex } from 'es-toolkit'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { defineStore, storeToRefs } from 'pinia'
|
import { defineStore, storeToRefs } from 'pinia'
|
||||||
import { ref, toRaw, watch } from 'vue'
|
import { computed, ref, shallowRef, toRaw, watch } from 'vue'
|
||||||
|
|
||||||
import { getEventSourceKey, getMetadataSourceLabel } from '../../../utils/event-source'
|
import { getEventSourceKey, getMetadataSourceLabel } from '../../../utils/event-source'
|
||||||
import { useLlmStreamingControlStore } from '../../ai/chat-llm/streaming-control'
|
import { useLlmStreamingControlStore } from '../../ai/chat-llm/streaming-control'
|
||||||
@@ -89,10 +89,61 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
|
|||||||
const { post: postSparkNotifyBridgeMessage, data: incomingSparkNotifyBridgeMessage } = useBroadcastChannel<SparkNotifyBridgeMessage, SparkNotifyBridgeMessage>({ name: SPARK_NOTIFY_BRIDGE_CHANNEL_NAME })
|
const { post: postSparkNotifyBridgeMessage, data: incomingSparkNotifyBridgeMessage } = useBroadcastChannel<SparkNotifyBridgeMessage, SparkNotifyBridgeMessage>({ name: SPARK_NOTIFY_BRIDGE_CHANNEL_NAME })
|
||||||
|
|
||||||
const disposeHookFns = ref<Array<() => void>>([])
|
const disposeHookFns = ref<Array<() => void>>([])
|
||||||
let remoteStreamGuard: { sessionId: string, generation: number } | null = null
|
// Remote stream data belongs to this renderer only. Keeping its visibility
|
||||||
|
// outside the synchronized chat store prevents a follower from publishing
|
||||||
|
// its local runtime state over the elected authority's snapshot.
|
||||||
|
const remoteStreamGuard = shallowRef<{
|
||||||
|
sessionId: string
|
||||||
|
generation: number
|
||||||
|
turnId: string
|
||||||
|
started: boolean
|
||||||
|
completed: boolean
|
||||||
|
refreshing: boolean
|
||||||
|
pendingLiterals: string[]
|
||||||
|
}>()
|
||||||
|
const isReceivingRemoteStream = computed(() => remoteStreamGuard.value?.sessionId === chatSession.activeSessionId)
|
||||||
let contextChannel: ReturnType<typeof createContextChannel> | undefined
|
let contextChannel: ReturnType<typeof createContextChannel> | undefined
|
||||||
let initialized = false
|
let initialized = false
|
||||||
|
|
||||||
|
function presentRemoteStreamIfActive() {
|
||||||
|
const guard = remoteStreamGuard.value
|
||||||
|
if (!guard || guard.sessionId !== chatSession.activeSessionId)
|
||||||
|
return false
|
||||||
|
if (chatSession.getSessionGenerationValue(guard.sessionId) !== guard.generation)
|
||||||
|
return false
|
||||||
|
|
||||||
|
if (!guard.started) {
|
||||||
|
guard.started = true
|
||||||
|
chatStream.beginStream(guard.turnId)
|
||||||
|
}
|
||||||
|
for (const literal of guard.pendingLiterals.splice(0))
|
||||||
|
chatStream.appendStreamLiteral(literal)
|
||||||
|
if (guard.completed && !guard.refreshing)
|
||||||
|
void refreshCompletedRemoteStream(guard)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCompletedRemoteStream(guard: NonNullable<typeof remoteStreamGuard.value>) {
|
||||||
|
guard.refreshing = true
|
||||||
|
let loaded = false
|
||||||
|
try {
|
||||||
|
loaded = await chatSession.refreshSession(guard.sessionId)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.warn('[context-bridge] Failed to refresh completed remote stream:', errorMessageFrom(error))
|
||||||
|
}
|
||||||
|
if (remoteStreamGuard.value !== guard)
|
||||||
|
return
|
||||||
|
guard.refreshing = false
|
||||||
|
if (!loaded || guard.sessionId !== chatSession.activeSessionId)
|
||||||
|
return
|
||||||
|
if (chatSession.getSessionGenerationValue(guard.sessionId) !== guard.generation)
|
||||||
|
return
|
||||||
|
|
||||||
|
chatStream.resetStream()
|
||||||
|
remoteStreamGuard.value = undefined
|
||||||
|
}
|
||||||
|
|
||||||
function recordContextIngestRejected(options: {
|
function recordContextIngestRejected(options: {
|
||||||
channel: 'server' | 'broadcast' | 'input'
|
channel: 'server' | 'broadcast' | 'input'
|
||||||
contextMessage: ContextMessage
|
contextMessage: ContextMessage
|
||||||
@@ -699,49 +750,49 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
|
|||||||
if (isProcessingRemoteStream)
|
if (isProcessingRemoteStream)
|
||||||
return
|
return
|
||||||
|
|
||||||
await contextChannel?.emitStream({ type: 'before-compose', message, sessionId: chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) })
|
await contextChannel?.emitStream({ type: 'before-compose', message, sessionId: chatOrchestrator.activeSendSessionId ?? chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) })
|
||||||
}),
|
}),
|
||||||
chatOrchestrator.onAfterMessageComposed(async (message, context) => {
|
chatOrchestrator.onAfterMessageComposed(async (message, context) => {
|
||||||
if (isProcessingRemoteStream)
|
if (isProcessingRemoteStream)
|
||||||
return
|
return
|
||||||
|
|
||||||
await contextChannel?.emitStream({ type: 'after-compose', message, sessionId: chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) })
|
await contextChannel?.emitStream({ type: 'after-compose', message, sessionId: chatOrchestrator.activeSendSessionId ?? chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) })
|
||||||
}),
|
}),
|
||||||
chatOrchestrator.onBeforeSend(async (message, context) => {
|
chatOrchestrator.onBeforeSend(async (message, context) => {
|
||||||
if (isProcessingRemoteStream)
|
if (isProcessingRemoteStream)
|
||||||
return
|
return
|
||||||
|
|
||||||
await contextChannel?.emitStream({ type: 'before-send', message, sessionId: chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) })
|
await contextChannel?.emitStream({ type: 'before-send', message, sessionId: chatOrchestrator.activeSendSessionId ?? chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) })
|
||||||
}),
|
}),
|
||||||
chatOrchestrator.onAfterSend(async (message, context) => {
|
chatOrchestrator.onAfterSend(async (message, context) => {
|
||||||
if (isProcessingRemoteStream)
|
if (isProcessingRemoteStream)
|
||||||
return
|
return
|
||||||
|
|
||||||
await contextChannel?.emitStream({ type: 'after-send', message, sessionId: chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) })
|
await contextChannel?.emitStream({ type: 'after-send', message, sessionId: chatOrchestrator.activeSendSessionId ?? chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) })
|
||||||
}),
|
}),
|
||||||
chatOrchestrator.onTokenLiteral(async (literal, context) => {
|
chatOrchestrator.onTokenLiteral(async (literal, context) => {
|
||||||
if (isProcessingRemoteStream)
|
if (isProcessingRemoteStream)
|
||||||
return
|
return
|
||||||
|
|
||||||
await contextChannel?.emitStream({ type: 'token-literal', literal, sessionId: chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) })
|
await contextChannel?.emitStream({ type: 'token-literal', literal, sessionId: chatOrchestrator.activeSendSessionId ?? chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) })
|
||||||
}),
|
}),
|
||||||
chatOrchestrator.onTokenSpecial(async (special, context) => {
|
chatOrchestrator.onTokenSpecial(async (special, context) => {
|
||||||
if (isProcessingRemoteStream)
|
if (isProcessingRemoteStream)
|
||||||
return
|
return
|
||||||
|
|
||||||
await contextChannel?.emitStream({ type: 'token-special', special, sessionId: chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) })
|
await contextChannel?.emitStream({ type: 'token-special', special, sessionId: chatOrchestrator.activeSendSessionId ?? chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) })
|
||||||
}),
|
}),
|
||||||
chatOrchestrator.onStreamEnd(async (context) => {
|
chatOrchestrator.onStreamEnd(async (context) => {
|
||||||
if (isProcessingRemoteStream)
|
if (isProcessingRemoteStream)
|
||||||
return
|
return
|
||||||
|
|
||||||
await contextChannel?.emitStream({ type: 'stream-end', sessionId: chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) })
|
await contextChannel?.emitStream({ type: 'stream-end', sessionId: chatOrchestrator.activeSendSessionId ?? chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) })
|
||||||
}),
|
}),
|
||||||
chatOrchestrator.onAssistantResponseEnd(async (message, context) => {
|
chatOrchestrator.onAssistantResponseEnd(async (message, context) => {
|
||||||
if (isProcessingRemoteStream)
|
if (isProcessingRemoteStream)
|
||||||
return
|
return
|
||||||
|
|
||||||
await contextChannel?.emitStream({ type: 'assistant-end', message, sessionId: chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) })
|
await contextChannel?.emitStream({ type: 'assistant-end', message, sessionId: chatOrchestrator.activeSendSessionId ?? chatSession.activeSessionId, context: structuredClone(normalizeContextSnapshot(context)) })
|
||||||
}),
|
}),
|
||||||
|
|
||||||
chatOrchestrator.onAssistantMessage(async (message, _messageText, context) => {
|
chatOrchestrator.onAssistantMessage(async (message, _messageText, context) => {
|
||||||
@@ -794,7 +845,8 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
|
|||||||
isProcessingRemoteStream = true
|
isProcessingRemoteStream = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use the receiver's active session to avoid clobbering chat state when events come from other windows/devtools.
|
// Remote UI state is correlated by session and generation. The
|
||||||
|
// receiver never persists these mirrored stream events.
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'before-compose':
|
case 'before-compose':
|
||||||
await chatOrchestrator.emitBeforeMessageComposedHooks(event.message, event.context)
|
await chatOrchestrator.emitBeforeMessageComposedHooks(event.message, event.context)
|
||||||
@@ -804,58 +856,99 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
|
|||||||
break
|
break
|
||||||
case 'before-send':
|
case 'before-send':
|
||||||
await chatOrchestrator.emitBeforeSendHooks(event.message, event.context)
|
await chatOrchestrator.emitBeforeSendHooks(event.message, event.context)
|
||||||
remoteStreamGuard = {
|
remoteStreamGuard.value = {
|
||||||
sessionId: chatSession.activeSessionId,
|
sessionId: event.sessionId,
|
||||||
generation: chatSession.getSessionGenerationValue(chatSession.activeSessionId),
|
generation: chatSession.getSessionGenerationValue(event.sessionId),
|
||||||
|
turnId: event.context.turnId,
|
||||||
|
started: false,
|
||||||
|
completed: false,
|
||||||
|
refreshing: false,
|
||||||
|
pendingLiterals: [],
|
||||||
}
|
}
|
||||||
chatOrchestrator.sending = true
|
presentRemoteStreamIfActive()
|
||||||
chatStream.beginStream(event.context.turnId)
|
|
||||||
break
|
break
|
||||||
case 'after-send':
|
case 'after-send':
|
||||||
await chatOrchestrator.emitAfterSendHooks(event.message, event.context)
|
await chatOrchestrator.emitAfterSendHooks(event.message, event.context)
|
||||||
break
|
break
|
||||||
case 'token-literal':
|
case 'token-literal':
|
||||||
if (!remoteStreamGuard)
|
if (!remoteStreamGuard.value)
|
||||||
return
|
return
|
||||||
if (remoteStreamGuard.sessionId !== chatSession.activeSessionId)
|
if (event.sessionId !== remoteStreamGuard.value.sessionId)
|
||||||
return
|
return
|
||||||
if (chatSession.getSessionGenerationValue(remoteStreamGuard.sessionId) !== remoteStreamGuard.generation)
|
if (chatSession.getSessionGenerationValue(remoteStreamGuard.value.sessionId) !== remoteStreamGuard.value.generation)
|
||||||
|
return
|
||||||
|
remoteStreamGuard.value.pendingLiterals.push(event.literal)
|
||||||
|
if (!presentRemoteStreamIfActive())
|
||||||
return
|
return
|
||||||
chatStream.appendStreamLiteral(event.literal)
|
|
||||||
await chatOrchestrator.emitTokenLiteralHooks(event.literal, event.context)
|
await chatOrchestrator.emitTokenLiteralHooks(event.literal, event.context)
|
||||||
break
|
break
|
||||||
case 'token-special':
|
case 'token-special':
|
||||||
|
if (!remoteStreamGuard.value || event.sessionId !== remoteStreamGuard.value.sessionId)
|
||||||
|
return
|
||||||
|
if (remoteStreamGuard.value.sessionId !== chatSession.activeSessionId)
|
||||||
|
return
|
||||||
|
if (chatSession.getSessionGenerationValue(remoteStreamGuard.value.sessionId) !== remoteStreamGuard.value.generation)
|
||||||
|
return
|
||||||
await chatOrchestrator.emitTokenSpecialHooks(event.special, event.context)
|
await chatOrchestrator.emitTokenSpecialHooks(event.special, event.context)
|
||||||
break
|
break
|
||||||
case 'stream-end':
|
case 'stream-end':
|
||||||
if (!remoteStreamGuard)
|
if (!remoteStreamGuard.value)
|
||||||
break
|
break
|
||||||
if (remoteStreamGuard.sessionId !== chatSession.activeSessionId)
|
{
|
||||||
break
|
const guard = remoteStreamGuard.value
|
||||||
if (chatSession.getSessionGenerationValue(remoteStreamGuard.sessionId) !== remoteStreamGuard.generation)
|
if (event.sessionId !== guard.sessionId)
|
||||||
break
|
break
|
||||||
await chatOrchestrator.emitStreamEndHooks(event.context)
|
if (guard.sessionId !== chatSession.activeSessionId
|
||||||
// NOTICE: Remote stream events are mirrored across renderer windows for UI feedback only.
|
&& chatSession.getSessionGenerationValue(guard.sessionId) === guard.generation) {
|
||||||
// Persisting them here would append assistant messages into the receiver's local session
|
break
|
||||||
// without the corresponding user message, corrupting IndexedDB history across windows.
|
}
|
||||||
chatStream.resetStream()
|
try {
|
||||||
chatOrchestrator.sending = false
|
if (guard.sessionId === chatSession.activeSessionId
|
||||||
remoteStreamGuard = null
|
&& chatSession.getSessionGenerationValue(guard.sessionId) === guard.generation) {
|
||||||
|
await chatOrchestrator.emitStreamEndHooks(event.context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (remoteStreamGuard.value === guard) {
|
||||||
|
if (guard.started
|
||||||
|
&& guard.sessionId === chatSession.activeSessionId
|
||||||
|
&& chatSession.getSessionGenerationValue(guard.sessionId) === guard.generation) {
|
||||||
|
chatStream.resetStream()
|
||||||
|
}
|
||||||
|
remoteStreamGuard.value = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
break
|
break
|
||||||
case 'assistant-end':
|
case 'assistant-end':
|
||||||
if (!remoteStreamGuard)
|
if (!remoteStreamGuard.value)
|
||||||
break
|
break
|
||||||
if (remoteStreamGuard.sessionId !== chatSession.activeSessionId)
|
{
|
||||||
break
|
const guard = remoteStreamGuard.value
|
||||||
if (chatSession.getSessionGenerationValue(remoteStreamGuard.sessionId) !== remoteStreamGuard.generation)
|
if (event.sessionId !== guard.sessionId)
|
||||||
break
|
break
|
||||||
await chatOrchestrator.emitAssistantResponseEndHooks(event.message, event.context)
|
if (guard.sessionId !== chatSession.activeSessionId
|
||||||
// NOTICE: The originating renderer already persists the final assistant message.
|
&& chatSession.getSessionGenerationValue(guard.sessionId) === guard.generation) {
|
||||||
// Receiver windows must not write it again, or they can overwrite the same session
|
guard.completed = true
|
||||||
// with assistant-only history when their local session state is stale.
|
break
|
||||||
chatStream.resetStream()
|
}
|
||||||
chatOrchestrator.sending = false
|
try {
|
||||||
remoteStreamGuard = null
|
if (guard.sessionId === chatSession.activeSessionId
|
||||||
|
&& chatSession.getSessionGenerationValue(guard.sessionId) === guard.generation) {
|
||||||
|
await chatOrchestrator.emitAssistantResponseEndHooks(event.message, event.context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (remoteStreamGuard.value === guard) {
|
||||||
|
if (guard.started
|
||||||
|
&& guard.sessionId === chatSession.activeSessionId
|
||||||
|
&& chatSession.getSessionGenerationValue(guard.sessionId) === guard.generation) {
|
||||||
|
chatStream.resetStream()
|
||||||
|
}
|
||||||
|
remoteStreamGuard.value = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -864,6 +957,11 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
disposeHookFns.value.push(stopIncomingStreamWatch)
|
disposeHookFns.value.push(stopIncomingStreamWatch)
|
||||||
|
disposeHookFns.value.push(watch(
|
||||||
|
() => chatSession.activeSessionId,
|
||||||
|
() => presentRemoteStreamIfActive(),
|
||||||
|
{ flush: 'sync' },
|
||||||
|
))
|
||||||
initialized = true
|
initialized = true
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -905,7 +1003,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
|
|||||||
contextChannel = undefined
|
contextChannel = undefined
|
||||||
|
|
||||||
initialized = false
|
initialized = false
|
||||||
remoteStreamGuard = null
|
remoteStreamGuard.value = undefined
|
||||||
|
|
||||||
for (const [requestId, waiter] of sparkNotifyBridgeWaiters) {
|
for (const [requestId, waiter] of sparkNotifyBridgeWaiters) {
|
||||||
if (waiter.timeout)
|
if (waiter.timeout)
|
||||||
@@ -925,6 +1023,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
|
|||||||
dispose,
|
dispose,
|
||||||
dispatchSparkNotifyReaction,
|
dispatchSparkNotifyReaction,
|
||||||
dispatchSparkNotifyPerformance,
|
dispatchSparkNotifyPerformance,
|
||||||
|
isReceivingRemoteStream,
|
||||||
setSparkNotifyHostRole,
|
setSparkNotifyHostRole,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ export default defineConfig({
|
|||||||
'server/apps/auth',
|
'server/apps/auth',
|
||||||
'server/apps/api',
|
'server/apps/api',
|
||||||
'apps/ui-server-auth',
|
'apps/ui-server-auth',
|
||||||
'apps/stage-tamagotchi',
|
'apps/stage-tamagotchi/vitest.node.config.ts',
|
||||||
'packages/cap-vite',
|
'packages/cap-vite',
|
||||||
'packages/ccc',
|
'packages/ccc',
|
||||||
'packages/core-agent',
|
'packages/core-agent',
|
||||||
|
|||||||
Reference in New Issue
Block a user