From c121c7b65ec6fc6406d961e6baac2aa0c30dc390 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Thu, 2 Jul 2026 11:11:51 +0100 Subject: [PATCH 01/10] fix: improve chat persistence --- .../nuxi/app/components/agent/AgentPanel.vue | 18 ++- .../app/components/agent/AgentPanelChat.vue | 42 ++++- layers/nuxi/app/composables/eve/init.ts | 57 ------- layers/nuxi/app/composables/eve/session.ts | 110 ------------- .../nuxi/app/composables/eve/thread-state.ts | 73 +++------ layers/nuxi/app/composables/eve/types.ts | 14 -- layers/nuxi/app/composables/eve/useEveChat.ts | 150 ++++++++++++++++++ layers/nuxi/app/composables/useAgentChat.ts | 50 ++---- layers/nuxi/app/composables/useChatResume.ts | 71 +++++++++ layers/nuxi/app/pages/dashboard/chat/[id].vue | 68 ++++---- 10 files changed, 336 insertions(+), 317 deletions(-) delete mode 100644 layers/nuxi/app/composables/eve/init.ts delete mode 100644 layers/nuxi/app/composables/eve/session.ts create mode 100644 layers/nuxi/app/composables/eve/useEveChat.ts create mode 100644 layers/nuxi/app/composables/useChatResume.ts diff --git a/layers/nuxi/app/components/agent/AgentPanel.vue b/layers/nuxi/app/components/agent/AgentPanel.vue index 53cb1ff89..db33c7723 100644 --- a/layers/nuxi/app/components/agent/AgentPanel.vue +++ b/layers/nuxi/app/components/agent/AgentPanel.vue @@ -14,15 +14,16 @@ const { const { chatList } = useChats() const { loggedIn } = useUserSession() -type ActiveChat = { id: string, messages: UIMessage[] } -const active = shallowRef({ id: crypto.randomUUID(), messages: [] }) +type ActiveChat = { id: string, messages: UIMessage[], state: ChatEveState | null } +const active = shallowRef({ id: crypto.randomUUID(), messages: [], state: null }) const chatId = computed(() => active.value.id) const initialMessages = computed(() => active.value.messages) +const initialState = computed(() => active.value.state) let loadToken = 0 function startNewChat() { loadToken++ - active.value = { id: crypto.randomUUID(), messages: [] } + active.value = { id: crypto.randomUUID(), messages: [], state: null } } async function setActiveChat(id: string) { @@ -31,10 +32,10 @@ async function setActiveChat(id: string) { try { const data = await $fetch(`/api/chats/${id}`) if (token !== loadToken) return - active.value = { id, messages: toUIMessages(data.messages ?? []) } + active.value = { id, messages: toUIMessages(data.messages ?? []), state: data.state ?? null } } catch { if (token === loadToken) { - active.value = { id: crypto.randomUUID(), messages: [] } + active.value = { id: crypto.randomUUID(), messages: [], state: null } } } } @@ -116,6 +117,11 @@ defineShortcuts({ - + diff --git a/layers/nuxi/app/components/agent/AgentPanelChat.vue b/layers/nuxi/app/components/agent/AgentPanelChat.vue index 2341d8d9d..b0ae8d135 100644 --- a/layers/nuxi/app/components/agent/AgentPanelChat.vue +++ b/layers/nuxi/app/components/agent/AgentPanelChat.vue @@ -4,6 +4,7 @@ import type { UIMessage } from 'ai' const props = defineProps<{ chatId: string initialMessages?: UIMessage[] + initialState?: ChatEveState | null }>() const { @@ -29,10 +30,12 @@ const { getVote, vote, askQuestion, - send + send, + hasAgentUser } = useAgentChat({ chatId: props.chatId, initialMessages: props.initialMessages, + initialState: props.initialState, source: 'prompt', withPageContext: 'when-enabled', onFinish: () => { @@ -58,9 +61,40 @@ watch(isOpen, (value) => { } }) -onMounted(() => { - const pendingPrompt = consumePendingPrompt() - if (pendingPrompt) send(pendingPrompt) +const resumeData = computed(() => { + if (!loggedIn.value || !props.initialMessages?.length) return undefined + + const rows: ChatMessageRow[] = props.initialMessages.map(message => ({ + id: message.id, + role: message.role, + parts: message.parts, + createdAt: (message.metadata as { createdAt?: string } | undefined)?.createdAt ?? new Date().toISOString() + })) + + if (!rows.some(message => message.role === 'user')) return undefined + if (rows.some(message => message.role === 'assistant')) return undefined + + return { + id: props.chatId, + title: null, + visibility: 'private', + isOwner: true, + createdAt: new Date().toISOString(), + state: props.initialState ?? null, + messages: rows + } +}) + +useChatResume({ + chatId: props.chatId, + chat, + send, + hasAgentUser, + data: resumeData, + loggedIn, + isOwner: computed(() => loggedIn.value), + consumePendingPrompt, + consumePendingMessageParts: () => null }) diff --git a/layers/nuxi/app/composables/eve/init.ts b/layers/nuxi/app/composables/eve/init.ts deleted file mode 100644 index 9d89d6564..000000000 --- a/layers/nuxi/app/composables/eve/init.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { UseEveAgentReturn, EveMessageData, UseEveAgentSnapshot } from 'eve/vue' -import type { ChatSessionOptions } from './types' - -const clientAgentsByChatId = import.meta.client - ? new Map>() - : null - -export interface EveAgentBindingOptions extends ChatSessionOptions { - headers?: () => Record - onFinish?: (snapshot: UseEveAgentSnapshot) => void | Promise -} - -function createAgent(chatId: string, options?: EveAgentBindingOptions) { - return useEveAgent({ - initialSession: options?.initialSession ?? { - continuationToken: chatId, - streamIndex: 0 - }, - initialEvents: options?.initialEvents as never, - headers: options?.headers, - onFinish: (snapshot) => { - void options?.onFinish?.(snapshot) - } - }) -} - -export function getOrCreateEveAgent(chatId: string, options?: EveAgentBindingOptions) { - if (import.meta.server) { - const state = useState | undefined>(`eve-agent:${chatId}`) - if (!state.value) { - state.value = createAgent(chatId, options) - } - return state.value - } - - let agent = clientAgentsByChatId!.get(chatId) - if (!agent) { - agent = createAgent(chatId, options) - clientAgentsByChatId!.set(chatId, agent) - } - return agent -} - -export function removeEveAgent(chatId: string) { - if (import.meta.server) { - useState | undefined>(`eve-agent:${chatId}`).value = undefined - return - } - clientAgentsByChatId?.delete(chatId) -} - -export function getEveAgent(chatId: string) { - if (import.meta.server) { - return useState | undefined>(`eve-agent:${chatId}`).value - } - return clientAgentsByChatId?.get(chatId) -} diff --git a/layers/nuxi/app/composables/eve/session.ts b/layers/nuxi/app/composables/eve/session.ts deleted file mode 100644 index 2e5ec1de8..000000000 --- a/layers/nuxi/app/composables/eve/session.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { EveMessageData } from 'eve/vue' -import type { FileUIPart, UIMessage } from 'ai' -import type { MaybeRefOrGetter } from 'vue' -import { computed, toValue } from 'vue' -import type { AgentChatHandle } from './types' -import type { EveAgentBindingOptions } from './init' -import { toUIMessages } from './adapter' -import { getOrCreateEveAgent } from './init' - -function lastUserMessage(data: EveMessageData) { - for (let index = data.messages.length - 1; index >= 0; index -= 1) { - const message = data.messages[index] - if (message?.role === 'user' && message.parts.length > 0) { - return message - } - } -} - -async function sendUserParts( - agent: ReturnType, - parts: UIMessage['parts'] -) { - const text = parts - .filter((part): part is { type: 'text', text: string } => part.type === 'text') - .map(part => part.text) - .join('\n') - .trim() - - const fileParts = parts.filter((part): part is FileUIPart => part.type === 'file') - - if (fileParts.length && text) { - await agent.send({ - message: [ - { type: 'text', text }, - ...fileParts.map(part => ({ - type: 'file' as const, - data: part.url, - mediaType: part.mediaType, - filename: part.filename - })) - ] - }) - return - } - - if (fileParts.length) { - await agent.send({ - message: fileParts.map(part => ({ - type: 'file' as const, - data: part.url, - mediaType: part.mediaType, - filename: part.filename - })) - }) - return - } - - if (text) { - await agent.send({ message: text }) - } -} - -export function createEveChatSession( - chatId: MaybeRefOrGetter, - options?: MaybeRefOrGetter -): AgentChatHandle & { - send: (input: string | { parts: UIMessage['parts'] }) => Promise -} { - const id = computed(() => toValue(chatId)) - const resolvedOptions = computed(() => toValue(options)) - const agent = computed(() => getOrCreateEveAgent(id.value, resolvedOptions.value)) - - const messages = computed(() => toUIMessages(agent.value.data.value.messages)) - const status = computed(() => agent.value.status.value) - const error = computed(() => agent.value.error.value) - - async function send(input: string | { parts: UIMessage['parts'] }) { - const parts = typeof input === 'string' - ? [{ type: 'text' as const, text: input }] - : input.parts - - await sendUserParts(agent.value, parts) - } - - function stop() { - agent.value.stop() - } - - async function regenerate() { - if (status.value === 'submitted' || status.value === 'streaming') return - const message = lastUserMessage(agent.value.data.value) - if (!message) return - await sendUserParts(agent.value, message.parts as UIMessage['parts']) - } - - return { - get messages() { - return messages.value - }, - get status() { - return status.value - }, - get error() { - return error.value - }, - send, - stop, - regenerate - } -} diff --git a/layers/nuxi/app/composables/eve/thread-state.ts b/layers/nuxi/app/composables/eve/thread-state.ts index fb4ea42eb..47acf0d3d 100644 --- a/layers/nuxi/app/composables/eve/thread-state.ts +++ b/layers/nuxi/app/composables/eve/thread-state.ts @@ -1,37 +1,15 @@ import type { EveMessageData, UseEveAgentSnapshot } from 'eve/vue' import type { UIMessage } from 'ai' -import type { ChatEveState, ChatDetail } from '../../../shared/types/chat' -import type { ChatSessionOptions } from './types' +import type { ChatEveState } from '../../../shared/types/chat' +import { toUIMessages } from './adapter' import { titleFromParts } from '../../../shared/utils/chat' -export function resumeOptionsFromChat(chat: Pick): ChatSessionOptions { - const events = chat.state?.events - if (!events?.length) { - return {} - } - - const session = chat.state?.session ?? { streamIndex: 0 } - - return { - initialSession: { - ...session, - streamIndex: Math.max(session.streamIndex ?? 0, events.length) - }, - initialEvents: events - } -} - -export async function persistChatState( +export async function syncChatToDb( chatId: string, snapshot: UseEveAgentSnapshot, - options?: { - syncMessages?: boolean - messages?: UIMessage[] - } + messages: UIMessage[] ) { - if (!snapshot.events.length) { - return - } + if (!snapshot.events.length) return const state: ChatEveState = { session: { @@ -46,21 +24,17 @@ export async function persistChatState( method: 'PATCH', body: { state, - ...(options?.syncMessages && options.messages?.length - ? { - messages: options.messages.map(message => ({ - id: message.id, - role: message.role, - parts: message.parts, - metadata: message.metadata as Record | undefined - })) - } - : {}) + messages: messages.map(message => ({ + id: message.id, + role: message.role, + parts: message.parts, + metadata: message.metadata as Record | undefined + })) } }) } -export async function persistAnonymousTitle(chatId: string, title: string) { +export function persistAnonymousTitle(chatId: string, title: string) { if (!import.meta.client) return sessionStorage.setItem(`nuxi-chat-title:${chatId}`, title) } @@ -70,33 +44,30 @@ export function readAnonymousTitle(chatId: string): string | null { return sessionStorage.getItem(`nuxi-chat-title:${chatId}`) } -export interface EveChatRuntimeOptions { +export interface SyncChatOptions { chatId: string - getMessages: () => UIMessage[] loggedIn: () => boolean - onTitle?: (title: string) => void - onFinish?: () => void refreshChats?: () => Promise patchTitle?: (chatId: string, title: string) => void findChatTitle?: (chatId: string) => string | null | undefined + onTitle?: (title: string) => void + onFinish?: () => void } -export function createEveFinishHandler(options: EveChatRuntimeOptions) { +export function createChatSyncHandler(options: SyncChatOptions) { return async (snapshot: UseEveAgentSnapshot) => { - const agentMessages = options.getMessages() + const messages = toUIMessages(snapshot.data.messages) if (options.loggedIn()) { try { - await persistChatState(options.chatId, snapshot, { - syncMessages: true, - messages: [...agentMessages] - }) + await syncChatToDb(options.chatId, snapshot, messages) + await refreshNuxtData(`chat-${options.chatId}`) await options.refreshChats?.() let generatedTitle = options.findChatTitle?.(options.chatId) ?? null if (!generatedTitle) { - const firstUser = [...agentMessages].find(message => message.role === 'user') + const firstUser = [...messages].find(message => message.role === 'user') const fallback = firstUser ? titleFromParts(firstUser.parts as UIMessage['parts']) : null @@ -117,10 +88,10 @@ export function createEveFinishHandler(options: EveChatRuntimeOptions) { // Non-fatal sync failure } } else { - const firstUser = [...agentMessages].find(message => message.role === 'user') + const firstUser = [...messages].find(message => message.role === 'user') if (firstUser) { const title = titleFromParts(firstUser.parts as UIMessage['parts']) - await persistAnonymousTitle(options.chatId, title) + persistAnonymousTitle(options.chatId, title) options.onTitle?.(title) } } diff --git a/layers/nuxi/app/composables/eve/types.ts b/layers/nuxi/app/composables/eve/types.ts index 3a3bd7d29..209953abd 100644 --- a/layers/nuxi/app/composables/eve/types.ts +++ b/layers/nuxi/app/composables/eve/types.ts @@ -9,17 +9,3 @@ export interface AgentChatHandle { stop: () => void regenerate: () => Promise } - -export interface ChatSessionOptions { - initialSession?: { - sessionId?: string - continuationToken?: string - streamIndex: number - } - initialEvents?: readonly unknown[] -} - -export interface EveStreamEvent { - type: string - [key: string]: unknown -} diff --git a/layers/nuxi/app/composables/eve/useEveChat.ts b/layers/nuxi/app/composables/eve/useEveChat.ts new file mode 100644 index 000000000..a3e0dd214 --- /dev/null +++ b/layers/nuxi/app/composables/eve/useEveChat.ts @@ -0,0 +1,150 @@ +import type { EveMessageData, UseEveAgentSnapshot } from 'eve/vue' +import { useEveAgent } from 'eve/vue' +import type { FileUIPart, UIMessage } from 'ai' +import type { ChatEveState } from '../../../shared/types/chat' +import { toUIMessages } from './adapter' +import type { AgentChatHandle } from './types' + +export interface UseEveChatOptions { + chatId: string + initialMessages?: UIMessage[] + initialState?: ChatEveState | null + headers?: () => Record + onFinish?: (snapshot: UseEveAgentSnapshot) => void | Promise +} + +function resumeFromState(chatId: string, state: ChatEveState | null | undefined) { + const events = state?.events + if (!events?.length) { + return { + initialSession: { + continuationToken: chatId, + streamIndex: 0 + } + } + } + + const session = state?.session ?? { streamIndex: 0 } + + return { + initialSession: { + ...session, + continuationToken: session.continuationToken ?? chatId, + streamIndex: Math.max(session.streamIndex ?? 0, events.length) + }, + initialEvents: events as never + } +} + +function lastUserMessage(data: EveMessageData) { + for (let index = data.messages.length - 1; index >= 0; index -= 1) { + const message = data.messages[index] + if (message?.role === 'user' && message.parts.length > 0) { + return message + } + } +} + +async function sendUserParts( + agent: ReturnType, + parts: UIMessage['parts'] +) { + const text = parts + .filter((part): part is { type: 'text', text: string } => part.type === 'text') + .map(part => part.text) + .join('\n') + .trim() + + const fileParts = parts.filter((part): part is FileUIPart => part.type === 'file') + + if (fileParts.length && text) { + await agent.send({ + message: [ + { type: 'text', text }, + ...fileParts.map(part => ({ + type: 'file' as const, + data: part.url, + mediaType: part.mediaType, + filename: part.filename + })) + ] + }) + return + } + + if (fileParts.length) { + await agent.send({ + message: fileParts.map(part => ({ + type: 'file' as const, + data: part.url, + mediaType: part.mediaType, + filename: part.filename + })) + }) + return + } + + if (text) { + await agent.send({ message: text }) + } +} + +export function useEveChat(options: UseEveChatOptions): AgentChatHandle & { + send: (input: string | { parts: UIMessage['parts'] }) => Promise + hasAgentMessage: (role: UIMessage['role']) => boolean +} { + const resume = resumeFromState(options.chatId, options.initialState) + + const agent = useEveAgent({ + ...resume, + headers: options.headers, + onFinish: (snapshot) => { + void options.onFinish?.(snapshot) + } + }) + + const messages = computed(() => { + const live = toUIMessages(agent.data.value.messages) + if (live.length > 0) return live + return options.initialMessages ?? [] + }) + + async function send(input: string | { parts: UIMessage['parts'] }) { + const parts = typeof input === 'string' + ? [{ type: 'text' as const, text: input }] + : input.parts + + await sendUserParts(agent, parts) + } + + async function regenerate() { + if (agent.status.value === 'submitted' || agent.status.value === 'streaming') return + const message = lastUserMessage(agent.data.value) + if (!message) return + await sendUserParts(agent, message.parts as UIMessage['parts']) + } + + function stop() { + agent.stop() + } + + function hasAgentMessage(role: UIMessage['role']) { + return agent.data.value.messages.some(message => message.role === role) + } + + return { + send, + hasAgentMessage, + get messages() { + return messages.value + }, + get status() { + return agent.status.value + }, + get error() { + return agent.error.value + }, + stop, + regenerate + } +} diff --git a/layers/nuxi/app/composables/useAgentChat.ts b/layers/nuxi/app/composables/useAgentChat.ts index f3d5c09a4..c211f439d 100644 --- a/layers/nuxi/app/composables/useAgentChat.ts +++ b/layers/nuxi/app/composables/useAgentChat.ts @@ -1,14 +1,8 @@ import type { UIMessage } from 'ai' import type { ChatEveState } from '../../shared/types/chat' import { buildMessageParts, getMessageTextLength } from '../../shared/utils/paste-attachment' -import { createEveChatSession } from './eve/session' -import { getOrCreateEveAgent, removeEveAgent } from './eve/init' -import { toUIMessages } from './eve/adapter' -import { - createEveFinishHandler, - readAnonymousTitle, - resumeOptionsFromChat -} from './eve/thread-state' +import { createChatSyncHandler, readAnonymousTitle } from './eve/thread-state' +import { useEveChat } from './eve/useEveChat' import { useChatVotes } from './useChatVotes' import { usePasteAttachment } from './usePasteAttachment' @@ -72,6 +66,7 @@ type AgentChatReturn = { send: (inputValue: string | { parts: UIMessage['parts'] }) => Promise onSubmit: () => Promise askQuestion: (question: string) => void + hasAgentUser: () => boolean readAnonymousTitle: () => string | null } @@ -156,42 +151,34 @@ export function useAgentChat(options: UseAgentChatOptions) { : agent.pageContextEnabled.value && Boolean(agent.currentPage.value) ) - const resumeOptions = computed(() => { - if (chatOptions.initialState) { - return resumeOptionsFromChat({ state: chatOptions.initialState }) - } - return {} - }) - - const eveSession = createEveChatSession(() => chatOptions.chatId, () => ({ - ...resumeOptions.value, + const eveChat = useEveChat({ + chatId: chatOptions.chatId, + initialMessages: chatOptions.initialMessages, + initialState: chatOptions.initialState, headers: buildEveHeaders(chatOptions.chatId, agent, useContext), - onFinish: createEveFinishHandler({ + onFinish: createChatSyncHandler({ chatId: chatOptions.chatId, loggedIn: () => loggedIn.value, - getMessages: () => toUIMessages(getOrCreateEveAgent(chatOptions.chatId).data.value.messages), refreshChats: () => chats.refresh(), patchTitle: (id, title) => chats.patchTitle(id, title), findChatTitle: id => chats.chatList.value?.find(c => c.id === id)?.title ?? null, onTitle: chatOptions.onTitle, onFinish: chatOptions.onFinish }) - })) + }) const chat = { get messages() { - const live = eveSession.messages - if (live.length > 0) return live - return chatOptions.initialMessages ?? [] + return eveChat.messages }, get status() { - return eveSession.status + return eveChat.status }, get error() { - return eveSession.error + return eveChat.error }, - stop: eveSession.stop, - regenerate: eveSession.regenerate + stop: eveChat.stop, + regenerate: eveChat.regenerate } async function persistFirstUserMessage(parts: UIMessage['parts']) { @@ -235,7 +222,7 @@ export function useAgentChat(options: UseAgentChatOptions) { await persistFirstUserMessage(parts) } - await eveSession.send(typeof inputValue === 'string' ? inputValue : { parts }) + await eveChat.send(typeof inputValue === 'string' ? inputValue : { parts }) agent.onMessageSent() } @@ -252,12 +239,6 @@ export function useAgentChat(options: UseAgentChatOptions) { send(question) } - if (import.meta.client) { - onUnmounted(() => { - removeEveAgent(chatOptions.chatId) - }) - } - return { chat, input, @@ -267,6 +248,7 @@ export function useAgentChat(options: UseAgentChatOptions) { send, onSubmit, askQuestion, + hasAgentUser: () => eveChat.hasAgentMessage('user'), readAnonymousTitle: () => readAnonymousTitle(chatOptions.chatId) } } diff --git a/layers/nuxi/app/composables/useChatResume.ts b/layers/nuxi/app/composables/useChatResume.ts new file mode 100644 index 000000000..0a8d3bb58 --- /dev/null +++ b/layers/nuxi/app/composables/useChatResume.ts @@ -0,0 +1,71 @@ +import type { UIMessage } from 'ai' + +interface ChatResumeOptions { + chatId: string + chat: { + regenerate: () => Promise + } + send: (input: { parts: UIMessage['parts'] }) => Promise + hasAgentUser: () => boolean + data: Ref + loggedIn: Ref + isOwner: Ref + consumePendingPrompt: () => string | null + consumePendingMessageParts: () => UIMessage['parts'] | null + onAnonymousTitle?: (parts: UIMessage['parts']) => void + redirectIfAnonymousEmpty?: () => void +} + +function needsGeneration(messages: ChatMessageRow[]) { + const hasAssistant = messages.some(message => message.role === 'assistant') + if (hasAssistant) return false + return messages.some(message => message.role === 'user') +} + +export function useChatResume(options: ChatResumeOptions) { + onMounted(() => { + if (!options.loggedIn.value) { + const pendingParts = options.consumePendingMessageParts() + const pendingPrompt = options.consumePendingPrompt() + + if (!pendingParts && !pendingPrompt) { + options.redirectIfAnonymousEmpty?.() + return + } + + if (pendingParts) { + options.onAnonymousTitle?.(pendingParts) + void options.send({ parts: pendingParts }) + return + } + + if (pendingPrompt) void options.send({ parts: [{ type: 'text', text: pendingPrompt }] }) + return + } + + const messages = options.data.value?.messages ?? [] + + if (options.isOwner.value && needsGeneration(messages)) { + const lastUserMessage = [...messages].reverse().find(message => message.role === 'user') + if (!lastUserMessage) return + + // Only regenerate when Eve already holds the user turn (resumed session). + // Display messages fall back to DB rows, so never use chat.messages here. + if (options.hasAgentUser()) { + void options.chat.regenerate() + } else { + void options.send({ parts: lastUserMessage.parts as UIMessage['parts'] }) + } + return + } + + const pendingParts = options.consumePendingMessageParts() + if (pendingParts) { + void options.send({ parts: pendingParts }) + return + } + + const pendingPrompt = options.consumePendingPrompt() + if (pendingPrompt) void options.send({ parts: [{ type: 'text', text: pendingPrompt }] }) + }) +} diff --git a/layers/nuxi/app/pages/dashboard/chat/[id].vue b/layers/nuxi/app/pages/dashboard/chat/[id].vue index 3f8fb8c84..a140780ed 100644 --- a/layers/nuxi/app/pages/dashboard/chat/[id].vue +++ b/layers/nuxi/app/pages/dashboard/chat/[id].vue @@ -1,9 +1,9 @@ From e0e6732eb180a8021be75e561adebc522fa1dabe Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Thu, 2 Jul 2026 13:19:09 +0100 Subject: [PATCH 02/10] fix: improve chat persistence --- .../app/components/agent/AgentChatBody.vue | 105 ++++++++ .../nuxi/app/components/agent/AgentPanel.vue | 2 +- .../app/components/agent/AgentPanelChat.vue | 196 +++++++-------- layers/nuxi/app/composables/eve/adapter.ts | 2 +- .../nuxi/app/composables/eve/thread-state.ts | 101 -------- layers/nuxi/app/composables/eve/useEveChat.ts | 4 +- layers/nuxi/app/composables/useAgentChat.ts | 229 +++++++++--------- .../app/composables/useAgentChatSession.ts | 143 +++++++++++ layers/nuxi/app/composables/useChatResume.ts | 71 ------ .../app/composables/usePasteAttachment.ts | 16 +- layers/nuxi/app/composables/useStartChat.ts | 66 +++++ layers/nuxi/app/pages/dashboard/chat/[id].vue | 104 ++++---- .../nuxi/app/pages/dashboard/chat/index.vue | 14 +- layers/nuxi/shared/utils/chat.ts | 12 +- 14 files changed, 596 insertions(+), 469 deletions(-) create mode 100644 layers/nuxi/app/components/agent/AgentChatBody.vue delete mode 100644 layers/nuxi/app/composables/eve/thread-state.ts create mode 100644 layers/nuxi/app/composables/useAgentChatSession.ts delete mode 100644 layers/nuxi/app/composables/useChatResume.ts create mode 100644 layers/nuxi/app/composables/useStartChat.ts diff --git a/layers/nuxi/app/components/agent/AgentChatBody.vue b/layers/nuxi/app/components/agent/AgentChatBody.vue new file mode 100644 index 000000000..ab26831c9 --- /dev/null +++ b/layers/nuxi/app/components/agent/AgentChatBody.vue @@ -0,0 +1,105 @@ + + + diff --git a/layers/nuxi/app/components/agent/AgentPanel.vue b/layers/nuxi/app/components/agent/AgentPanel.vue index db33c7723..6930875f7 100644 --- a/layers/nuxi/app/components/agent/AgentPanel.vue +++ b/layers/nuxi/app/components/agent/AgentPanel.vue @@ -32,7 +32,7 @@ async function setActiveChat(id: string) { try { const data = await $fetch(`/api/chats/${id}`) if (token !== loadToken) return - active.value = { id, messages: toUIMessages(data.messages ?? []), state: data.state ?? null } + active.value = { id, messages: dbRowsToUIMessages(data.messages ?? []), state: data.state ?? null } } catch { if (token === loadToken) { active.value = { id: crypto.randomUUID(), messages: [], state: null } diff --git a/layers/nuxi/app/components/agent/AgentPanelChat.vue b/layers/nuxi/app/components/agent/AgentPanelChat.vue index b0ae8d135..64ddfb306 100644 --- a/layers/nuxi/app/components/agent/AgentPanelChat.vue +++ b/layers/nuxi/app/components/agent/AgentPanelChat.vue @@ -1,5 +1,6 @@ diff --git a/layers/nuxi/app/composables/eve/adapter.ts b/layers/nuxi/app/composables/eve/adapter.ts index 6d2283999..64c79ed95 100644 --- a/layers/nuxi/app/composables/eve/adapter.ts +++ b/layers/nuxi/app/composables/eve/adapter.ts @@ -2,7 +2,7 @@ import type { UIMessage } from 'ai' import type { EveMessage } from 'eve/vue' import type { AgentChatStatus } from './types' -export function toUIMessages(messages: readonly EveMessage[]): UIMessage[] { +export function eveMessagesToUIMessages(messages: readonly EveMessage[]): UIMessage[] { return [...messages] as UIMessage[] } diff --git a/layers/nuxi/app/composables/eve/thread-state.ts b/layers/nuxi/app/composables/eve/thread-state.ts deleted file mode 100644 index 47acf0d3d..000000000 --- a/layers/nuxi/app/composables/eve/thread-state.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { EveMessageData, UseEveAgentSnapshot } from 'eve/vue' -import type { UIMessage } from 'ai' -import type { ChatEveState } from '../../../shared/types/chat' -import { toUIMessages } from './adapter' -import { titleFromParts } from '../../../shared/utils/chat' - -export async function syncChatToDb( - chatId: string, - snapshot: UseEveAgentSnapshot, - messages: UIMessage[] -) { - if (!snapshot.events.length) return - - const state: ChatEveState = { - session: { - sessionId: snapshot.session.sessionId, - continuationToken: snapshot.session.continuationToken ?? chatId, - streamIndex: snapshot.events.length - }, - events: [...snapshot.events] - } - - await $fetch(`/api/chats/${chatId}/state`, { - method: 'PATCH', - body: { - state, - messages: messages.map(message => ({ - id: message.id, - role: message.role, - parts: message.parts, - metadata: message.metadata as Record | undefined - })) - } - }) -} - -export function persistAnonymousTitle(chatId: string, title: string) { - if (!import.meta.client) return - sessionStorage.setItem(`nuxi-chat-title:${chatId}`, title) -} - -export function readAnonymousTitle(chatId: string): string | null { - if (!import.meta.client) return null - return sessionStorage.getItem(`nuxi-chat-title:${chatId}`) -} - -export interface SyncChatOptions { - chatId: string - loggedIn: () => boolean - refreshChats?: () => Promise - patchTitle?: (chatId: string, title: string) => void - findChatTitle?: (chatId: string) => string | null | undefined - onTitle?: (title: string) => void - onFinish?: () => void -} - -export function createChatSyncHandler(options: SyncChatOptions) { - return async (snapshot: UseEveAgentSnapshot) => { - const messages = toUIMessages(snapshot.data.messages) - - if (options.loggedIn()) { - try { - await syncChatToDb(options.chatId, snapshot, messages) - await refreshNuxtData(`chat-${options.chatId}`) - await options.refreshChats?.() - - let generatedTitle = options.findChatTitle?.(options.chatId) ?? null - - if (!generatedTitle) { - const firstUser = [...messages].find(message => message.role === 'user') - const fallback = firstUser - ? titleFromParts(firstUser.parts as UIMessage['parts']) - : null - if (fallback && fallback !== 'Untitled') { - await $fetch(`/api/chats/${options.chatId}/title`, { - method: 'PATCH', - body: { title: fallback } - }) - generatedTitle = fallback - } - } - - if (generatedTitle) { - options.patchTitle?.(options.chatId, generatedTitle) - options.onTitle?.(generatedTitle) - } - } catch { - // Non-fatal sync failure - } - } else { - const firstUser = [...messages].find(message => message.role === 'user') - if (firstUser) { - const title = titleFromParts(firstUser.parts as UIMessage['parts']) - persistAnonymousTitle(options.chatId, title) - options.onTitle?.(title) - } - } - - options.onFinish?.() - } -} diff --git a/layers/nuxi/app/composables/eve/useEveChat.ts b/layers/nuxi/app/composables/eve/useEveChat.ts index a3e0dd214..8d3914a13 100644 --- a/layers/nuxi/app/composables/eve/useEveChat.ts +++ b/layers/nuxi/app/composables/eve/useEveChat.ts @@ -2,7 +2,7 @@ import type { EveMessageData, UseEveAgentSnapshot } from 'eve/vue' import { useEveAgent } from 'eve/vue' import type { FileUIPart, UIMessage } from 'ai' import type { ChatEveState } from '../../../shared/types/chat' -import { toUIMessages } from './adapter' +import { eveMessagesToUIMessages } from './adapter' import type { AgentChatHandle } from './types' export interface UseEveChatOptions { @@ -104,7 +104,7 @@ export function useEveChat(options: UseEveChatOptions): AgentChatHandle & { }) const messages = computed(() => { - const live = toUIMessages(agent.data.value.messages) + const live = eveMessagesToUIMessages(agent.data.value.messages) if (live.length > 0) return live return options.initialMessages ?? [] }) diff --git a/layers/nuxi/app/composables/useAgentChat.ts b/layers/nuxi/app/composables/useAgentChat.ts index c211f439d..bb84754e5 100644 --- a/layers/nuxi/app/composables/useAgentChat.ts +++ b/layers/nuxi/app/composables/useAgentChat.ts @@ -1,13 +1,20 @@ +import type { EveMessageData, UseEveAgentSnapshot } from 'eve/vue' import type { UIMessage } from 'ai' import type { ChatEveState } from '../../shared/types/chat' -import { buildMessageParts, getMessageTextLength } from '../../shared/utils/paste-attachment' -import { createChatSyncHandler, readAnonymousTitle } from './eve/thread-state' +import { getMessageTextLength } from '../../shared/utils/paste-attachment' +import { + appendUserMessageToChat, + createChatWithMessage, + persistAnonymousTitle, + readAnonymousTitle, + titleFromParts +} from '../../shared/utils/chat' +import { eveMessagesToUIMessages } from './eve/adapter' import { useEveChat } from './eve/useEveChat' import { useChatVotes } from './useChatVotes' import { usePasteAttachment } from './usePasteAttachment' -type ChatModeOptions = { - mode?: 'chat' +export interface UseAgentChatOptions { chatId: string initialMessages?: UIMessage[] initialState?: ChatEveState | null @@ -18,12 +25,91 @@ type ChatModeOptions = { onTitle?: (title: string) => void } -type StartModeOptions = { - mode: 'start' - source: string +interface SyncChatOptions { + chatId: string + loggedIn: () => boolean + refreshChats?: () => Promise + patchTitle?: (chatId: string, title: string) => void + findChatTitle?: (chatId: string) => string | null | undefined + onTitle?: (title: string) => void + onFinish?: () => void } -export type UseAgentChatOptions = ChatModeOptions | StartModeOptions +async function syncChatToDb( + chatId: string, + snapshot: UseEveAgentSnapshot, + messages: UIMessage[] +) { + if (!snapshot.events.length) return + + const state: ChatEveState = { + session: { + sessionId: snapshot.session.sessionId, + continuationToken: snapshot.session.continuationToken ?? chatId, + streamIndex: snapshot.events.length + }, + events: [...snapshot.events] + } + + await $fetch(`/api/chats/${chatId}/state`, { + method: 'PATCH', + body: { + state, + messages: messages.map(message => ({ + id: message.id, + role: message.role, + parts: message.parts, + metadata: message.metadata as Record | undefined + })) + } + }) +} + +function createChatSyncHandler(options: SyncChatOptions) { + return async (snapshot: UseEveAgentSnapshot) => { + const messages = eveMessagesToUIMessages(snapshot.data.messages) + + if (options.loggedIn()) { + try { + await syncChatToDb(options.chatId, snapshot, messages) + await refreshNuxtData(`chat-${options.chatId}`) + await options.refreshChats?.() + + let generatedTitle = options.findChatTitle?.(options.chatId) ?? null + + if (!generatedTitle) { + const firstUser = [...messages].find(message => message.role === 'user') + const fallback = firstUser + ? titleFromParts(firstUser.parts as UIMessage['parts']) + : null + if (fallback && fallback !== 'Untitled') { + await $fetch(`/api/chats/${options.chatId}/title`, { + method: 'PATCH', + body: { title: fallback } + }) + generatedTitle = fallback + } + } + + if (generatedTitle) { + options.patchTitle?.(options.chatId, generatedTitle) + options.onTitle?.(generatedTitle) + } + } catch { + // Non-fatal sync failure + } + } else { + const firstUser = [...messages].find(message => message.role === 'user') + if (firstUser) { + const title = titleFromParts(firstUser.parts as UIMessage['parts']) + persistAnonymousTitle(options.chatId, title) + options.onTitle?.(title) + } + } + + options.onFinish?.() + } +} function buildEveHeaders( chatId: string, @@ -43,97 +129,7 @@ function buildEveHeaders( } } -type StartChatReturn = { - input: Ref - loading: Ref - prompt: ReturnType['bindings']> - onSubmit: () => Promise - createFromSuggestion: (label: string) => Promise -} - -type AgentChatReturn = { - chat: { - messages: UIMessage[] - status: 'ready' | 'submitted' | 'streaming' | 'error' - error?: Error - stop: () => void - regenerate: () => Promise - } - input: Ref - prompt: ReturnType['bindings']> - getVote: (messageId: string) => boolean | null - vote: (message: UIMessage, isUpvoted: boolean) => void - send: (inputValue: string | { parts: UIMessage['parts'] }) => Promise - onSubmit: () => Promise - askQuestion: (question: string) => void - hasAgentUser: () => boolean - readAnonymousTitle: () => string | null -} - -function useStartChat(source: string): StartChatReturn { - const agent = useNuxtAgent() - const chats = useChats() - const { loggedIn } = useUserSession() - const { track } = useAnalytics() - const toast = useToast() - - const input = ref('') - const paste = usePasteAttachment(input) - const loading = ref(false) - - async function createChat(parts: UIMessage['parts']) { - if (loading.value || agent.rateLimitReached.value || getMessageTextLength(parts) === 0) return - loading.value = true - - try { - if (loggedIn.value) { - const chatId = crypto.randomUUID() - await createChatWithMessage(chatId, parts) - await chats.refresh() - await navigateTo(`/dashboard/chat/${chatId}`) - } else { - agent.pendingMessageParts.value = parts - await navigateTo(`/dashboard/chat/${crypto.randomUUID()}`) - } - } catch { - toast.add({ description: 'Failed to create chat', icon: 'i-lucide-alert-circle', color: 'error' }) - } finally { - loading.value = false - } - } - - async function onSubmit() { - if (!paste.canSubmit.value) return - const parts = paste.buildMessageParts() - paste.clearAttachments() - input.value = '' - track('Nuxi Message Sent', { source, queryLength: getMessageTextLength(parts) }) - await createChat(parts) - } - - function createFromSuggestion(label: string) { - track('Nuxi FAQ Clicked', { question: label, source }) - return createChat(buildMessageParts(label, [])) - } - - return { - input, - loading, - prompt: paste.bindings(onSubmit), - onSubmit, - createFromSuggestion - } -} - -export function useAgentChat( - options: T -): T extends StartModeOptions ? StartChatReturn : AgentChatReturn export function useAgentChat(options: UseAgentChatOptions) { - if (options.mode === 'start') { - return useStartChat(options.source) - } - - const chatOptions = options const agent = useNuxtAgent() const chats = useChats() const { loggedIn } = useUserSession() @@ -141,29 +137,29 @@ export function useAgentChat(options: UseAgentChatOptions) { const input = ref('') const paste = usePasteAttachment(input) - const { getVote, vote } = useChatVotes(() => chatOptions.chatId, chatOptions.fetchVotes ?? false) + const { getVote, vote } = useChatVotes(() => options.chatId, options.fetchVotes ?? false) - const initialDbPersistDone = ref((chatOptions.initialMessages?.length ?? 0) > 0) + const initialDbPersistDone = ref((options.initialMessages?.length ?? 0) > 0) const useContext = computed(() => - chatOptions.withPageContext === 'always' + options.withPageContext === 'always' ? Boolean(agent.currentPage.value) : agent.pageContextEnabled.value && Boolean(agent.currentPage.value) ) const eveChat = useEveChat({ - chatId: chatOptions.chatId, - initialMessages: chatOptions.initialMessages, - initialState: chatOptions.initialState, - headers: buildEveHeaders(chatOptions.chatId, agent, useContext), + chatId: options.chatId, + initialMessages: options.initialMessages, + initialState: options.initialState, + headers: buildEveHeaders(options.chatId, agent, useContext), onFinish: createChatSyncHandler({ - chatId: chatOptions.chatId, + chatId: options.chatId, loggedIn: () => loggedIn.value, refreshChats: () => chats.refresh(), patchTitle: (id, title) => chats.patchTitle(id, title), findChatTitle: id => chats.chatList.value?.find(c => c.id === id)?.title ?? null, - onTitle: chatOptions.onTitle, - onFinish: chatOptions.onFinish + onTitle: options.onTitle, + onFinish: options.onFinish }) }) @@ -191,10 +187,10 @@ export function useAgentChat(options: UseAgentChatOptions) { } try { - if (chatOptions.initialMessages?.length) { - await appendUserMessageToChat(chatOptions.chatId, parts, metadata) + if (options.initialMessages?.length) { + await appendUserMessageToChat(options.chatId, parts, metadata) } else { - await createChatWithMessage(chatOptions.chatId, parts, metadata) + await createChatWithMessage(options.chatId, parts, metadata) } } catch (error) { initialDbPersistDone.value = false @@ -212,7 +208,7 @@ export function useAgentChat(options: UseAgentChatOptions) { if (!parts.length || getMessageTextLength(parts) === 0 || agent.rateLimitReached.value) return track('Nuxi Message Sent', { - source: chatOptions.source, + source: options.source, page: agent.currentPage.value, withContext: useContext.value, queryLength: getMessageTextLength(parts) @@ -235,20 +231,23 @@ export function useAgentChat(options: UseAgentChatOptions) { } function askQuestion(question: string) { - track('Nuxi FAQ Clicked', { question, source: chatOptions.source }) + track('Nuxi FAQ Clicked', { question, source: options.source }) send(question) } return { chat, input, - prompt: paste.bindings(onSubmit), + prompt: paste.prompt, getVote, vote, send, onSubmit, askQuestion, + handlePaste: paste.handlePaste, + removeAttachment: paste.removeAttachment, + restoreToInput: paste.restoreToInput, hasAgentUser: () => eveChat.hasAgentMessage('user'), - readAnonymousTitle: () => readAnonymousTitle(chatOptions.chatId) + readAnonymousTitle: () => readAnonymousTitle(options.chatId) } } diff --git a/layers/nuxi/app/composables/useAgentChatSession.ts b/layers/nuxi/app/composables/useAgentChatSession.ts new file mode 100644 index 000000000..b6ac77126 --- /dev/null +++ b/layers/nuxi/app/composables/useAgentChatSession.ts @@ -0,0 +1,143 @@ +import type { UIMessage } from 'ai' +import type { ChatEveState } from '../../shared/types/chat' +import { useAgentChat, type UseAgentChatOptions } from './useAgentChat' + +export interface UseAgentChatSessionOptions extends UseAgentChatOptions { + data?: Ref + isOwner?: Ref + consumePendingPrompt?: () => string | null + consumePendingMessageParts?: () => UIMessage['parts'] | null + onAnonymousTitle?: (parts: UIMessage['parts']) => void + redirectIfAnonymousEmpty?: () => void +} + +function needsGeneration(messages: ChatMessageRow[]) { + const hasAssistant = messages.some(message => message.role === 'assistant') + if (hasAssistant) return false + return messages.some(message => message.role === 'user') +} + +export function chatDetailForResume( + chatId: string, + initialMessages: UIMessage[] | undefined, + initialState: ChatEveState | null | undefined, + fetched?: ChatDetail +): ChatDetail | undefined { + if (fetched) return fetched + if (!initialMessages?.length) return undefined + + const rows: ChatMessageRow[] = initialMessages.map(message => ({ + id: message.id, + role: message.role, + parts: message.parts, + createdAt: (message.metadata as { createdAt?: string } | undefined)?.createdAt ?? new Date().toISOString() + })) + + if (!needsGeneration(rows)) return undefined + + return { + id: chatId, + title: null, + visibility: 'private', + isOwner: true, + createdAt: new Date().toISOString(), + state: initialState ?? null, + messages: rows + } +} + +export async function refreshChatIfIncomplete(chatId: string, data: Ref) { + const cached = data.value + const looksIncomplete = cached?.messages.some(message => message.role === 'user') + && !cached?.messages.some(message => message.role === 'assistant') + if (looksIncomplete) { + await refreshNuxtData(`chat-${chatId}`) + } +} + +function resumeChatSession(options: { + chat: ReturnType['chat'] + send: ReturnType['send'] + hasAgentUser: ReturnType['hasAgentUser'] + data: Ref + loggedIn: Ref + isOwner: Ref + consumePendingPrompt: () => string | null + consumePendingMessageParts: () => UIMessage['parts'] | null + onAnonymousTitle?: (parts: UIMessage['parts']) => void + redirectIfAnonymousEmpty?: () => void +}) { + if (!options.loggedIn.value) { + const pendingParts = options.consumePendingMessageParts() + const pendingPrompt = options.consumePendingPrompt() + + if (!pendingParts && !pendingPrompt) { + options.redirectIfAnonymousEmpty?.() + return + } + + if (pendingParts) { + options.onAnonymousTitle?.(pendingParts) + void options.send({ parts: pendingParts }) + return + } + + if (pendingPrompt) void options.send({ parts: [{ type: 'text', text: pendingPrompt }] }) + return + } + + const messages = options.data.value?.messages ?? [] + + if (options.isOwner.value && needsGeneration(messages)) { + const lastUserMessage = [...messages].reverse().find(message => message.role === 'user') + if (!lastUserMessage) return + + if (options.hasAgentUser()) { + void options.chat.regenerate() + } else { + void options.send({ parts: lastUserMessage.parts as UIMessage['parts'] }) + } + return + } + + const pendingParts = options.consumePendingMessageParts() + if (pendingParts) { + void options.send({ parts: pendingParts }) + return + } + + const pendingPrompt = options.consumePendingPrompt() + if (pendingPrompt) void options.send({ parts: [{ type: 'text', text: pendingPrompt }] }) +} + +export function useAgentChatSession(options: UseAgentChatSessionOptions) { + const { loggedIn } = useUserSession() + + const resumeData = computed(() => chatDetailForResume( + options.chatId, + options.initialMessages, + options.initialState, + options.data?.value + )) + + const session = useAgentChat(options) + + const isOwner = options.isOwner ?? computed(() => loggedIn.value) + + onMounted(() => { + resumeChatSession({ + chat: session.chat, + send: session.send, + hasAgentUser: session.hasAgentUser, + data: options.data ?? resumeData, + loggedIn, + isOwner, + consumePendingPrompt: options.consumePendingPrompt ?? (() => null), + consumePendingMessageParts: options.consumePendingMessageParts ?? (() => null), + onAnonymousTitle: options.onAnonymousTitle, + redirectIfAnonymousEmpty: options.redirectIfAnonymousEmpty + }) + }) + + return session +} diff --git a/layers/nuxi/app/composables/useChatResume.ts b/layers/nuxi/app/composables/useChatResume.ts deleted file mode 100644 index 0a8d3bb58..000000000 --- a/layers/nuxi/app/composables/useChatResume.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { UIMessage } from 'ai' - -interface ChatResumeOptions { - chatId: string - chat: { - regenerate: () => Promise - } - send: (input: { parts: UIMessage['parts'] }) => Promise - hasAgentUser: () => boolean - data: Ref - loggedIn: Ref - isOwner: Ref - consumePendingPrompt: () => string | null - consumePendingMessageParts: () => UIMessage['parts'] | null - onAnonymousTitle?: (parts: UIMessage['parts']) => void - redirectIfAnonymousEmpty?: () => void -} - -function needsGeneration(messages: ChatMessageRow[]) { - const hasAssistant = messages.some(message => message.role === 'assistant') - if (hasAssistant) return false - return messages.some(message => message.role === 'user') -} - -export function useChatResume(options: ChatResumeOptions) { - onMounted(() => { - if (!options.loggedIn.value) { - const pendingParts = options.consumePendingMessageParts() - const pendingPrompt = options.consumePendingPrompt() - - if (!pendingParts && !pendingPrompt) { - options.redirectIfAnonymousEmpty?.() - return - } - - if (pendingParts) { - options.onAnonymousTitle?.(pendingParts) - void options.send({ parts: pendingParts }) - return - } - - if (pendingPrompt) void options.send({ parts: [{ type: 'text', text: pendingPrompt }] }) - return - } - - const messages = options.data.value?.messages ?? [] - - if (options.isOwner.value && needsGeneration(messages)) { - const lastUserMessage = [...messages].reverse().find(message => message.role === 'user') - if (!lastUserMessage) return - - // Only regenerate when Eve already holds the user turn (resumed session). - // Display messages fall back to DB rows, so never use chat.messages here. - if (options.hasAgentUser()) { - void options.chat.regenerate() - } else { - void options.send({ parts: lastUserMessage.parts as UIMessage['parts'] }) - } - return - } - - const pendingParts = options.consumePendingMessageParts() - if (pendingParts) { - void options.send({ parts: pendingParts }) - return - } - - const pendingPrompt = options.consumePendingPrompt() - if (pendingPrompt) void options.send({ parts: [{ type: 'text', text: pendingPrompt }] }) - }) -} diff --git a/layers/nuxi/app/composables/usePasteAttachment.ts b/layers/nuxi/app/composables/usePasteAttachment.ts index 1d0a437f6..016bff814 100644 --- a/layers/nuxi/app/composables/usePasteAttachment.ts +++ b/layers/nuxi/app/composables/usePasteAttachment.ts @@ -43,16 +43,10 @@ export function usePasteAttachment(input: Ref) { attachments.value = [] } - function bindings(onSubmit: () => void | Promise) { - return computed(() => ({ - pasteAttachments: attachments.value, - canSubmit: canSubmit.value, - onPaste: handlePaste, - onRemoveAttachment: removeAttachment, - onRestoreAttachment: restoreToInput, - onSubmit - })) - } + const prompt = computed(() => ({ + pasteAttachments: attachments.value, + canSubmit: canSubmit.value + })) return { attachments, @@ -62,6 +56,6 @@ export function usePasteAttachment(input: Ref) { restoreToInput, buildMessageParts: (): UIMessage['parts'] => buildMessageParts(input.value, attachments.value), clearAttachments, - bindings + prompt } } diff --git a/layers/nuxi/app/composables/useStartChat.ts b/layers/nuxi/app/composables/useStartChat.ts new file mode 100644 index 000000000..d5bac0499 --- /dev/null +++ b/layers/nuxi/app/composables/useStartChat.ts @@ -0,0 +1,66 @@ +import type { UIMessage } from 'ai' +import { buildMessageParts, getMessageTextLength } from '../../shared/utils/paste-attachment' +import { createChatWithMessage } from '../../shared/utils/chat' +import { usePasteAttachment } from './usePasteAttachment' + +export function useStartChat(source: string) { + const agent = useNuxtAgent() + const chats = useChats() + const { loggedIn } = useUserSession() + const { track } = useAnalytics() + const toast = useToast() + + const input = ref('') + const paste = usePasteAttachment(input) + const loading = ref(false) + + async function createChat(parts: UIMessage['parts']) { + if (loading.value || agent.rateLimitReached.value || getMessageTextLength(parts) === 0) return + loading.value = true + + try { + if (loggedIn.value) { + const chatId = crypto.randomUUID() + await createChatWithMessage(chatId, parts) + await useFetch(() => `/api/chats/${chatId}`, { + key: `chat-${chatId}`, + server: false + }) + await navigateTo(`/dashboard/chat/${chatId}`) + void chats.refresh() + } else { + agent.pendingMessageParts.value = parts + await navigateTo(`/dashboard/chat/${crypto.randomUUID()}`) + } + } catch { + toast.add({ description: 'Failed to create chat', icon: 'i-lucide-alert-circle', color: 'error' }) + } finally { + loading.value = false + } + } + + async function onSubmit() { + if (!paste.canSubmit.value) return + const parts = paste.buildMessageParts() + paste.clearAttachments() + input.value = '' + track('Nuxi Message Sent', { source, queryLength: getMessageTextLength(parts) }) + await createChat(parts) + } + + function createFromSuggestion(label: string) { + track('Nuxi FAQ Clicked', { question: label, source }) + return createChat(buildMessageParts(label, [])) + } + + return { + input, + loading, + prompt: paste.prompt, + onSubmit, + handlePaste: paste.handlePaste, + removeAttachment: paste.removeAttachment, + restoreToInput: paste.restoreToInput, + createFromSuggestion + } +} diff --git a/layers/nuxi/app/pages/dashboard/chat/[id].vue b/layers/nuxi/app/pages/dashboard/chat/[id].vue index a140780ed..2406e4df5 100644 --- a/layers/nuxi/app/pages/dashboard/chat/[id].vue +++ b/layers/nuxi/app/pages/dashboard/chat/[id].vue @@ -1,5 +1,5 @@ @@ -103,6 +111,10 @@ const suggestions = [ :submit-disabled="!prompt.canSubmit" class="[view-transition-name:chat-prompt]" :ui="{ base: 'px-1.5', footer: 'items-baseline', header: 'px-1.5 pt-1.5 pb-0 gap-1.5 flex flex-wrap items-start' }" + @submit="onSubmit" + @paste="handlePaste" + @remove-attachment="removeAttachment" + @restore-attachment="restoreToInput" /> diff --git a/layers/nuxi/shared/utils/chat.ts b/layers/nuxi/shared/utils/chat.ts index b9029a1ec..0213726be 100644 --- a/layers/nuxi/shared/utils/chat.ts +++ b/layers/nuxi/shared/utils/chat.ts @@ -1,7 +1,7 @@ import type { UIMessage } from 'ai' import { format } from 'date-fns' -export function toUIMessages(rows: ChatMessageRow[]): UIMessage[] { +export function dbRowsToUIMessages(rows: ChatMessageRow[]): UIMessage[] { return rows.map(m => ({ id: m.id, role: m.role, @@ -76,3 +76,13 @@ export async function appendUserMessageToChat( return userMessage } + +export function persistAnonymousTitle(chatId: string, title: string) { + if (!import.meta.client) return + sessionStorage.setItem(`nuxi-chat-title:${chatId}`, title) +} + +export function readAnonymousTitle(chatId: string): string | null { + if (!import.meta.client) return null + return sessionStorage.getItem(`nuxi-chat-title:${chatId}`) +} From fdaee15d001e0ae3095cbe4cfa0ae801f8018c36 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Thu, 2 Jul 2026 13:41:01 +0100 Subject: [PATCH 03/10] perf(nuxi): optimise chat navigation and caching --- layers/nuxi/app/composables/eve/useEveChat.ts | 10 +- layers/nuxi/app/composables/useAgentChat.ts | 31 +++++-- .../app/composables/useAgentChatSession.ts | 73 +++++++++------ layers/nuxi/app/composables/useChatDetail.ts | 20 ++++ .../app/composables/useChatDetailCache.ts | 56 +++++++++++ layers/nuxi/app/composables/useChats.ts | 3 +- layers/nuxi/app/composables/useStartChat.ts | 9 +- layers/nuxi/app/pages/dashboard/chat/[id].vue | 40 ++++---- .../plugins/chat-view-transitions.client.ts | 93 +++++++++++++++++++ layers/nuxi/server/api/chats/index.post.ts | 19 +++- layers/nuxi/shared/utils/chat.ts | 6 +- nuxt.config.ts | 1 - 12 files changed, 295 insertions(+), 66 deletions(-) create mode 100644 layers/nuxi/app/composables/useChatDetail.ts create mode 100644 layers/nuxi/app/composables/useChatDetailCache.ts create mode 100644 layers/nuxi/app/plugins/chat-view-transitions.client.ts diff --git a/layers/nuxi/app/composables/eve/useEveChat.ts b/layers/nuxi/app/composables/eve/useEveChat.ts index 8d3914a13..abc067d2f 100644 --- a/layers/nuxi/app/composables/eve/useEveChat.ts +++ b/layers/nuxi/app/composables/eve/useEveChat.ts @@ -7,8 +7,8 @@ import type { AgentChatHandle } from './types' export interface UseEveChatOptions { chatId: string - initialMessages?: UIMessage[] - initialState?: ChatEveState | null + initialMessages?: MaybeRefOrGetter + initialState?: MaybeRefOrGetter headers?: () => Record onFinish?: (snapshot: UseEveAgentSnapshot) => void | Promise } @@ -93,7 +93,7 @@ export function useEveChat(options: UseEveChatOptions): AgentChatHandle & { send: (input: string | { parts: UIMessage['parts'] }) => Promise hasAgentMessage: (role: UIMessage['role']) => boolean } { - const resume = resumeFromState(options.chatId, options.initialState) + const resume = resumeFromState(options.chatId, toValue(options.initialState)) const agent = useEveAgent({ ...resume, @@ -103,10 +103,12 @@ export function useEveChat(options: UseEveChatOptions): AgentChatHandle & { } }) + const seedMessages = computed(() => toValue(options.initialMessages) ?? []) + const messages = computed(() => { const live = eveMessagesToUIMessages(agent.data.value.messages) if (live.length > 0) return live - return options.initialMessages ?? [] + return seedMessages.value }) async function send(input: string | { parts: UIMessage['parts'] }) { diff --git a/layers/nuxi/app/composables/useAgentChat.ts b/layers/nuxi/app/composables/useAgentChat.ts index bb84754e5..03e012692 100644 --- a/layers/nuxi/app/composables/useAgentChat.ts +++ b/layers/nuxi/app/composables/useAgentChat.ts @@ -9,6 +9,7 @@ import { readAnonymousTitle, titleFromParts } from '../../shared/utils/chat' +import { patchChatDetailCache, seedChatDetailCache, uiMessagesToRows } from './useChatDetailCache' import { eveMessagesToUIMessages } from './eve/adapter' import { useEveChat } from './eve/useEveChat' import { useChatVotes } from './useChatVotes' @@ -16,8 +17,8 @@ import { usePasteAttachment } from './usePasteAttachment' export interface UseAgentChatOptions { chatId: string - initialMessages?: UIMessage[] - initialState?: ChatEveState | null + initialMessages?: MaybeRefOrGetter + initialState?: MaybeRefOrGetter source: string withPageContext?: 'always' | 'when-enabled' fetchVotes?: boolean @@ -72,7 +73,17 @@ function createChatSyncHandler(options: SyncChatOptions) { if (options.loggedIn()) { try { await syncChatToDb(options.chatId, snapshot, messages) - await refreshNuxtData(`chat-${options.chatId}`) + patchChatDetailCache(options.chatId, { + state: { + session: { + sessionId: snapshot.session.sessionId, + continuationToken: snapshot.session.continuationToken ?? options.chatId, + streamIndex: snapshot.events.length + }, + events: [...snapshot.events] + }, + messages: uiMessagesToRows(messages) + }) await options.refreshChats?.() let generatedTitle = options.findChatTitle?.(options.chatId) ?? null @@ -139,7 +150,14 @@ export function useAgentChat(options: UseAgentChatOptions) { const paste = usePasteAttachment(input) const { getVote, vote } = useChatVotes(() => options.chatId, options.fetchVotes ?? false) - const initialDbPersistDone = ref((options.initialMessages?.length ?? 0) > 0) + const initialDbPersistDone = ref((toValue(options.initialMessages)?.length ?? 0) > 0) + + watch( + () => toValue(options.initialMessages)?.length ?? 0, + (length) => { + if (length > 0) initialDbPersistDone.value = true + } + ) const useContext = computed(() => options.withPageContext === 'always' @@ -187,10 +205,11 @@ export function useAgentChat(options: UseAgentChatOptions) { } try { - if (options.initialMessages?.length) { + if ((toValue(options.initialMessages)?.length ?? 0) > 0) { await appendUserMessageToChat(options.chatId, parts, metadata) } else { - await createChatWithMessage(options.chatId, parts, metadata) + const detail = await createChatWithMessage(options.chatId, parts, metadata) + seedChatDetailCache(options.chatId, detail) } } catch (error) { initialDbPersistDone.value = false diff --git a/layers/nuxi/app/composables/useAgentChatSession.ts b/layers/nuxi/app/composables/useAgentChatSession.ts index b6ac77126..2a98b44ca 100644 --- a/layers/nuxi/app/composables/useAgentChatSession.ts +++ b/layers/nuxi/app/composables/useAgentChatSession.ts @@ -1,9 +1,11 @@ import type { UIMessage } from 'ai' import type { ChatEveState } from '../../shared/types/chat' +import { consumeFreshChat } from './useChatDetailCache' import { useAgentChat, type UseAgentChatOptions } from './useAgentChat' export interface UseAgentChatSessionOptions extends UseAgentChatOptions { - data?: Ref + data?: Ref + dataStatus?: Ref isOwner?: Ref consumePendingPrompt?: () => string | null consumePendingMessageParts?: () => UIMessage['parts'] | null @@ -21,7 +23,7 @@ export function chatDetailForResume( chatId: string, initialMessages: UIMessage[] | undefined, initialState: ChatEveState | null | undefined, - fetched?: ChatDetail + fetched?: ChatDetail | null ): ChatDetail | undefined { if (fetched) return fetched if (!initialMessages?.length) return undefined @@ -46,20 +48,28 @@ export function chatDetailForResume( } } -export async function refreshChatIfIncomplete(chatId: string, data: Ref) { - const cached = data.value - const looksIncomplete = cached?.messages.some(message => message.role === 'user') - && !cached?.messages.some(message => message.role === 'assistant') - if (looksIncomplete) { - await refreshNuxtData(`chat-${chatId}`) - } +export function setupStaleChatRefresh(options: { + chatId: string + data: Ref + status: Ref + refresh: () => Promise +}) { + onMounted(() => { + if (consumeFreshChat(options.chatId)) return + + const cached = options.data.value + if (!cached || options.status.value !== 'success') return + + const looksIncomplete = needsGeneration(cached.messages) + if (looksIncomplete) void options.refresh() + }) } function resumeChatSession(options: { chat: ReturnType['chat'] send: ReturnType['send'] hasAgentUser: ReturnType['hasAgentUser'] - data: Ref + data: Ref loggedIn: Ref isOwner: Ref consumePendingPrompt: () => string | null @@ -115,29 +125,38 @@ export function useAgentChatSession(options: UseAgentChatSessionOptions) { const resumeData = computed(() => chatDetailForResume( options.chatId, - options.initialMessages, - options.initialState, + toValue(options.initialMessages), + toValue(options.initialState), options.data?.value )) const session = useAgentChat(options) const isOwner = options.isOwner ?? computed(() => loggedIn.value) - - onMounted(() => { - resumeChatSession({ - chat: session.chat, - send: session.send, - hasAgentUser: session.hasAgentUser, - data: options.data ?? resumeData, - loggedIn, - isOwner, - consumePendingPrompt: options.consumePendingPrompt ?? (() => null), - consumePendingMessageParts: options.consumePendingMessageParts ?? (() => null), - onAnonymousTitle: options.onAnonymousTitle, - redirectIfAnonymousEmpty: options.redirectIfAnonymousEmpty - }) - }) + const resumeDone = ref(false) + + watch( + [() => options.data?.value, () => options.dataStatus?.value, loggedIn], + () => { + if (resumeDone.value) return + if (loggedIn.value && options.dataStatus?.value === 'pending') return + + resumeDone.value = true + resumeChatSession({ + chat: session.chat, + send: session.send, + hasAgentUser: session.hasAgentUser, + data: options.data ?? resumeData, + loggedIn, + isOwner, + consumePendingPrompt: options.consumePendingPrompt ?? (() => null), + consumePendingMessageParts: options.consumePendingMessageParts ?? (() => null), + onAnonymousTitle: options.onAnonymousTitle, + redirectIfAnonymousEmpty: options.redirectIfAnonymousEmpty + }) + }, + { immediate: true } + ) return session } diff --git a/layers/nuxi/app/composables/useChatDetail.ts b/layers/nuxi/app/composables/useChatDetail.ts new file mode 100644 index 000000000..e0ae50d34 --- /dev/null +++ b/layers/nuxi/app/composables/useChatDetail.ts @@ -0,0 +1,20 @@ +import { chatDetailCacheKey } from './useChatDetailCache' + +export function useChatDetail(chatId: MaybeRefOrGetter) { + const { loggedIn } = useUserSession() + const nuxtApp = useNuxtApp() + const id = computed(() => toValue(chatId)) + + return useLazyAsyncData( + () => chatDetailCacheKey(id.value), + () => { + if (!loggedIn.value) return Promise.resolve(null) + return $fetch(`/api/chats/${id.value}`) + }, + { + server: false, + watch: [id, loggedIn], + getCachedData: key => nuxtApp.payload.data[key] ?? nuxtApp.static.data[key] + } + ) +} diff --git a/layers/nuxi/app/composables/useChatDetailCache.ts b/layers/nuxi/app/composables/useChatDetailCache.ts new file mode 100644 index 000000000..0955938a5 --- /dev/null +++ b/layers/nuxi/app/composables/useChatDetailCache.ts @@ -0,0 +1,56 @@ +import type { UIMessage } from 'ai' + +export function chatDetailCacheKey(chatId: string) { + return `chat-${chatId}` +} + +export function uiMessagesToRows(messages: UIMessage[]): ChatMessageRow[] { + return messages.map(message => ({ + id: message.id, + role: message.role, + parts: message.parts, + createdAt: (message.metadata as { createdAt?: string } | undefined)?.createdAt ?? new Date().toISOString() + })) +} + +export function seedChatDetailCache(chatId: string, detail: ChatDetail) { + if (!import.meta.client) return + + const nuxtApp = useNuxtApp() + const key = chatDetailCacheKey(chatId) + nuxtApp.payload.data[key] = detail + + const { data } = useNuxtData(key) + data.value = detail +} + +export function patchChatDetailCache( + chatId: string, + patch: Partial> +) { + if (!import.meta.client) return + + const key = chatDetailCacheKey(chatId) + const { data } = useNuxtData(key) + if (!data.value) return + + data.value = { ...data.value, ...patch } + + const nuxtApp = useNuxtApp() + nuxtApp.payload.data[key] = data.value +} + +const freshChatKey = (chatId: string) => `nuxi-fresh-chat:${chatId}` + +export function markChatAsFresh(chatId: string) { + if (!import.meta.client) return + sessionStorage.setItem(freshChatKey(chatId), '1') +} + +export function consumeFreshChat(chatId: string) { + if (!import.meta.client) return false + const key = freshChatKey(chatId) + if (!sessionStorage.getItem(key)) return false + sessionStorage.removeItem(key) + return true +} diff --git a/layers/nuxi/app/composables/useChats.ts b/layers/nuxi/app/composables/useChats.ts index 018cd0ab9..5af7cf1bb 100644 --- a/layers/nuxi/app/composables/useChats.ts +++ b/layers/nuxi/app/composables/useChats.ts @@ -1,5 +1,6 @@ import { LazyChatModalConfirm, LazyChatModalRename } from '#components' import { isToday, isYesterday, subWeeks, subMonths } from 'date-fns' +import { chatDetailCacheKey } from './useChatDetailCache' function groupChats(chats: UIChat[] | undefined | null) { const today: UIChat[] = [] @@ -70,7 +71,7 @@ export function useChats() { if (chatList.value) { chatList.value = chatList.value.map(c => c.id === id ? { ...c, title } : c) } - const { data: chatCache } = useNuxtData(`chat-${id}`) + const { data: chatCache } = useNuxtData(chatDetailCacheKey(id)) if (chatCache.value) { chatCache.value = { ...chatCache.value, title } } diff --git a/layers/nuxi/app/composables/useStartChat.ts b/layers/nuxi/app/composables/useStartChat.ts index d5bac0499..002cdcedf 100644 --- a/layers/nuxi/app/composables/useStartChat.ts +++ b/layers/nuxi/app/composables/useStartChat.ts @@ -1,6 +1,7 @@ import type { UIMessage } from 'ai' import { buildMessageParts, getMessageTextLength } from '../../shared/utils/paste-attachment' import { createChatWithMessage } from '../../shared/utils/chat' +import { markChatAsFresh, seedChatDetailCache } from './useChatDetailCache' import { usePasteAttachment } from './usePasteAttachment' export function useStartChat(source: string) { @@ -21,11 +22,9 @@ export function useStartChat(source: string) { try { if (loggedIn.value) { const chatId = crypto.randomUUID() - await createChatWithMessage(chatId, parts) - await useFetch(() => `/api/chats/${chatId}`, { - key: `chat-${chatId}`, - server: false - }) + const detail = await createChatWithMessage(chatId, parts) + seedChatDetailCache(chatId, detail) + markChatAsFresh(chatId) await navigateTo(`/dashboard/chat/${chatId}`) void chats.refresh() } else { diff --git a/layers/nuxi/app/pages/dashboard/chat/[id].vue b/layers/nuxi/app/pages/dashboard/chat/[id].vue index 2406e4df5..5f1bb70ac 100644 --- a/layers/nuxi/app/pages/dashboard/chat/[id].vue +++ b/layers/nuxi/app/pages/dashboard/chat/[id].vue @@ -1,5 +1,6 @@