diff --git a/app/layouts/dashboard.vue b/app/layouts/dashboard.vue index 7959129c5..692c19f66 100644 --- a/app/layouts/dashboard.vue +++ b/app/layouts/dashboard.vue @@ -9,15 +9,10 @@ const { renameChat, deleteChat, groups, refresh: refreshChats } = useChats() const sidebarOpen = ref(false) -await useFetch('/api/chats', { - key: 'chats', - default: () => [] -}) - watch(loggedIn, () => { refreshChats() sidebarOpen.value = false -}) +}, { immediate: true }) const items = computed(() => groups.value?.flatMap(group => [ { label: group.label, type: 'label' as const }, 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/AgentChatMessages.vue b/layers/nuxi/app/components/agent/AgentChatMessages.vue index b74564206..fc61db484 100644 --- a/layers/nuxi/app/components/agent/AgentChatMessages.vue +++ b/layers/nuxi/app/components/agent/AgentChatMessages.vue @@ -30,6 +30,16 @@ const emit = defineEmits<{ const display = computed(() => resolveChatDisplayState(props.chat.messages, props.chat.status) ) + +function isAssistantPending(message: UIMessage): boolean { + if (message.role !== 'assistant') return false + + const { status, messages } = props.chat + if (status !== 'submitted' && status !== 'streaming') return false + + const last = messages.at(-1) + return last?.role === 'assistant' && message.id === last.id +} - + diff --git a/layers/nuxi/app/components/agent/AgentPanelChat.vue b/layers/nuxi/app/components/agent/AgentPanelChat.vue index 2341d8d9d..c8a290ecb 100644 --- a/layers/nuxi/app/components/agent/AgentPanelChat.vue +++ b/layers/nuxi/app/components/agent/AgentPanelChat.vue @@ -1,9 +1,11 @@ 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/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 deleted file mode 100644 index fb4ea42eb..000000000 --- a/layers/nuxi/app/composables/eve/thread-state.ts +++ /dev/null @@ -1,130 +0,0 @@ -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 { 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( - chatId: string, - snapshot: UseEveAgentSnapshot, - options?: { - syncMessages?: boolean - 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, - ...(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 - })) - } - : {}) - } - }) -} - -export async 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 EveChatRuntimeOptions { - 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 -} - -export function createEveFinishHandler(options: EveChatRuntimeOptions) { - return async (snapshot: UseEveAgentSnapshot) => { - const agentMessages = options.getMessages() - - if (options.loggedIn()) { - try { - await persistChatState(options.chatId, snapshot, { - syncMessages: true, - messages: [...agentMessages] - }) - await options.refreshChats?.() - - let generatedTitle = options.findChatTitle?.(options.chatId) ?? null - - if (!generatedTitle) { - const firstUser = [...agentMessages].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 = [...agentMessages].find(message => message.role === 'user') - if (firstUser) { - const title = titleFromParts(firstUser.parts as UIMessage['parts']) - await persistAnonymousTitle(options.chatId, title) - options.onTitle?.(title) - } - } - - options.onFinish?.() - } -} 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..3e8a15c89 --- /dev/null +++ b/layers/nuxi/app/composables/eve/useEveChat.ts @@ -0,0 +1,152 @@ +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 { eveMessagesToUIMessages } from './adapter' +import type { AgentChatHandle } from './types' + +export interface UseEveChatOptions { + chatId: MaybeRefOrGetter + initialMessages?: MaybeRefOrGetter + initialState?: MaybeRefOrGetter + 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(toValue(options.chatId), toValue(options.initialState)) + + const agent = useEveAgent({ + ...resume, + headers: options.headers, + onFinish: (snapshot) => { + void options.onFinish?.(snapshot) + } + }) + + const seedMessages = computed(() => toValue(options.initialMessages) ?? []) + + const messages = computed(() => { + const live = eveMessagesToUIMessages(agent.data.value.messages) + if (live.length > 0) return live + return seedMessages.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, 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 deleted file mode 100644 index f3d5c09a4..000000000 --- a/layers/nuxi/app/composables/useAgentChat.ts +++ /dev/null @@ -1,272 +0,0 @@ -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 { useChatVotes } from './useChatVotes' -import { usePasteAttachment } from './usePasteAttachment' - -type ChatModeOptions = { - mode?: 'chat' - chatId: string - initialMessages?: UIMessage[] - initialState?: ChatEveState | null - source: string - withPageContext?: 'always' | 'when-enabled' - fetchVotes?: boolean - onFinish?: () => void - onTitle?: (title: string) => void -} - -type StartModeOptions = { - mode: 'start' - source: string -} - -export type UseAgentChatOptions = ChatModeOptions | StartModeOptions - -function buildEveHeaders( - chatId: string, - agent: ReturnType, - withPageContext: ComputedRef -) { - return () => { - const headers: Record = { - 'x-nuxi-chat-id': chatId - } - - if (withPageContext.value && agent.currentPage.value) { - headers['x-page-path'] = agent.currentPage.value - } - - return headers - } -} - -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 - 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() - const { track } = useAnalytics() - - const input = ref('') - const paste = usePasteAttachment(input) - const { getVote, vote } = useChatVotes(() => chatOptions.chatId, chatOptions.fetchVotes ?? false) - - const initialDbPersistDone = ref((chatOptions.initialMessages?.length ?? 0) > 0) - - const useContext = computed(() => - chatOptions.withPageContext === 'always' - ? Boolean(agent.currentPage.value) - : 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, - headers: buildEveHeaders(chatOptions.chatId, agent, useContext), - onFinish: createEveFinishHandler({ - 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 ?? [] - }, - get status() { - return eveSession.status - }, - get error() { - return eveSession.error - }, - stop: eveSession.stop, - regenerate: eveSession.regenerate - } - - async function persistFirstUserMessage(parts: UIMessage['parts']) { - if (initialDbPersistDone.value) return - initialDbPersistDone.value = true - - const metadata = { - createdAt: new Date().toISOString(), - ...(useContext.value && agent.currentPage.value ? { pagePath: agent.currentPage.value } : {}) - } - - try { - if (chatOptions.initialMessages?.length) { - await appendUserMessageToChat(chatOptions.chatId, parts, metadata) - } else { - await createChatWithMessage(chatOptions.chatId, parts, metadata) - } - } catch (error) { - initialDbPersistDone.value = false - throw error - } - } - - type SendInput = string | { parts: UIMessage['parts'] } - - async function send(inputValue: SendInput) { - const parts = typeof inputValue === 'string' - ? [{ type: 'text' as const, text: inputValue }] - : inputValue.parts - - if (!parts.length || getMessageTextLength(parts) === 0 || agent.rateLimitReached.value) return - - track('Nuxi Message Sent', { - source: chatOptions.source, - page: agent.currentPage.value, - withContext: useContext.value, - queryLength: getMessageTextLength(parts) - }) - - if (loggedIn.value) { - await persistFirstUserMessage(parts) - } - - await eveSession.send(typeof inputValue === 'string' ? inputValue : { parts }) - agent.onMessageSent() - } - - async function onSubmit() { - if (!paste.canSubmit.value) return - const parts = paste.buildMessageParts() - input.value = '' - paste.clearAttachments() - await send({ parts }) - } - - function askQuestion(question: string) { - track('Nuxi FAQ Clicked', { question, source: chatOptions.source }) - send(question) - } - - if (import.meta.client) { - onUnmounted(() => { - removeEveAgent(chatOptions.chatId) - }) - } - - return { - chat, - input, - prompt: paste.bindings(onSubmit), - getVote, - vote, - send, - onSubmit, - askQuestion, - readAnonymousTitle: () => readAnonymousTitle(chatOptions.chatId) - } -} diff --git a/layers/nuxi/app/composables/useChatDetail.ts b/layers/nuxi/app/composables/useChatDetail.ts new file mode 100644 index 000000000..ae16ca38a --- /dev/null +++ b/layers/nuxi/app/composables/useChatDetail.ts @@ -0,0 +1,178 @@ +import type { UIMessage } from 'ai' + +const navigationChatKey = 'nuxi-navigation-chat-id' + +export function setNavigationChatId(chatId: string) { + if (!import.meta.client) return + sessionStorage.setItem(navigationChatKey, chatId) +} + +export function readNavigationChatId() { + if (!import.meta.client) return '' + return sessionStorage.getItem(navigationChatKey) ?? '' +} + +export function clearNavigationChatId() { + if (!import.meta.client) return + sessionStorage.removeItem(navigationChatKey) +} + +export function chatDetailCacheKey(chatId: string) { + if (!chatId) return 'chat-pending' + return `chat-${chatId}` +} + +export function uiMessagesToRows( + messages: UIMessage[], + existing?: ChatMessageRow[] +): ChatMessageRow[] { + const existingById = new Map(existing?.map(row => [row.id, row.createdAt])) + + return messages.map(message => ({ + id: message.id, + role: message.role, + parts: message.parts, + createdAt: (message.metadata as { createdAt?: string } | undefined)?.createdAt + ?? existingById.get(message.id) + ?? 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 + + let resolved = patch + if (patch.messages?.length && data.value.messages?.length) { + const existingById = new Map(data.value.messages.map(row => [row.id, row.createdAt])) + resolved = { + ...patch, + messages: patch.messages.map(row => ({ + ...row, + createdAt: existingById.get(row.id) ?? row.createdAt + })) + } + } + + data.value = { ...data.value, ...resolved } + + 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 +} + +export function resolveChatRouteId(path: string, param: string | string[] | undefined) { + if (typeof param === 'string' && param) return param + if (Array.isArray(param) && param[0]) return param[0] + + const match = path.match(/^\/dashboard\/chat\/([^/]+)\/?$/) + if (match?.[1]) return match[1] + + return readNavigationChatId() +} + +export function useChatRouteId() { + const route = useRoute() + return computed(() => resolveChatRouteId(route.path, route.params.id)) +} + +export function useChatDetail(chatId: MaybeRefOrGetter) { + const route = useRoute() + const { loggedIn } = useUserSession() + + const id = computed(() => { + const value = toValue(chatId) + if (value) return value + return resolveChatRouteId(route.path, route.params.id) + }) + + const data = ref(null) + const error = ref(null) + const status = ref<'idle' | 'pending' | 'success' | 'error'>('idle') + + let stopCacheWatch: ReturnType | undefined + + function bindCache(chatIdValue: string) { + stopCacheWatch?.() + const { data: shared } = useNuxtData(chatDetailCacheKey(chatIdValue)) + data.value = shared.value ?? null + stopCacheWatch = watch(shared, (value) => { + data.value = value ?? null + }, { deep: true }) + } + + onScopeDispose(() => stopCacheWatch?.()) + + async function refresh() { + const chatIdValue = id.value + + if (!loggedIn.value || !chatIdValue) { + stopCacheWatch?.() + data.value = null + error.value = null + status.value = 'success' + return + } + + bindCache(chatIdValue) + + if (data.value) { + error.value = null + status.value = 'success' + return + } + + status.value = 'pending' + error.value = null + + try { + const detail = await $fetch(`/api/chats/${chatIdValue}`) + if (id.value !== chatIdValue) return + seedChatDetailCache(chatIdValue, detail) + error.value = null + status.value = 'success' + } catch (err) { + if (id.value !== chatIdValue) return + data.value = null + error.value = err as Error + status.value = 'error' + } + } + + watch([id, loggedIn], () => { + void refresh() + }, { immediate: true }) + + return { data, error, status, refresh } +} diff --git a/layers/nuxi/app/composables/useChatVotes.ts b/layers/nuxi/app/composables/useChatVotes.ts deleted file mode 100644 index 696390534..000000000 --- a/layers/nuxi/app/composables/useChatVotes.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { UIMessage } from 'ai' - -export function useChatVotes(chatId: MaybeRefOrGetter, immediate = false) { - const toast = useToast() - const chatIdValue = computed(() => toValue(chatId)) - - const { data: voteRows } = useLazyFetch( - () => `/api/chats/${chatIdValue.value}/votes`, - { - immediate, - default: () => [] as ChatVoteRow[] - } - ) - - const votes = ref(new Map()) - - watch(voteRows, (rows) => { - const map = new Map() - for (const row of (rows ?? []) as ChatVoteRow[]) { - map.set(row.messageId, row.isUpvoted) - } - votes.value = map - }, { immediate: true }) - - function getRows(): ChatVoteRow[] { - return (voteRows.value ?? []) as ChatVoteRow[] - } - - function getVote(messageId: string): boolean | null { - const vote = votes.value.get(messageId) - return vote === undefined ? null : vote - } - - function vote(message: UIMessage, isUpvoted: boolean) { - const current = votes.value.get(message.id) - const next = current === isUpvoted ? undefined : isUpvoted - const snapshot = new Map(votes.value) - - if (next === undefined) votes.value.delete(message.id) - else votes.value.set(message.id, next) - votes.value = new Map(votes.value) - - voteRows.value = next === undefined - ? getRows().filter(row => row.messageId !== message.id) - : [ - ...getRows().filter(row => row.messageId !== message.id), - { chatId: chatIdValue.value, messageId: message.id, isUpvoted: next } - ] - - $fetch(`/api/chats/${chatIdValue.value}/votes`, { - method: 'POST', - body: next === undefined - ? { messageId: message.id } - : { messageId: message.id, isUpvoted: next } - }).catch(() => { - votes.value = snapshot - voteRows.value = [...snapshot.entries()].map(([messageId, isUpvoted]) => ({ - chatId: chatIdValue.value, - messageId, - isUpvoted - })) - toast.add({ description: 'Failed to save vote', icon: 'i-lucide-alert-circle', color: 'error' }) - }) - } - - return { votes, getVote, vote } -} diff --git a/layers/nuxi/app/composables/useChats.ts b/layers/nuxi/app/composables/useChats.ts index 018cd0ab9..28c6ac23a 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 './useChatDetail' function groupChats(chats: UIChat[] | undefined | null) { const today: UIChat[] = [] @@ -46,6 +47,7 @@ export function useChats() { const route = useRoute() const toast = useToast() const overlay = useOverlay() + const { loggedIn } = useUserSession() const { data: chatList } = useFetch('/api/chats', { key: 'chats', @@ -70,7 +72,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 } } @@ -83,6 +85,10 @@ export function useChats() { } async function refresh() { + if (!loggedIn.value) { + if (chatList.value?.length) chatList.value = [] + return + } await refreshNuxtData('chats') } diff --git a/layers/nuxi/app/composables/useNuxiChat.ts b/layers/nuxi/app/composables/useNuxiChat.ts new file mode 100644 index 000000000..e763cdd35 --- /dev/null +++ b/layers/nuxi/app/composables/useNuxiChat.ts @@ -0,0 +1,499 @@ +import type { EveMessageData, UseEveAgentSnapshot } from 'eve/vue' +import type { UIMessage } from 'ai' +import type { ChatEveState } from '../../shared/types/chat' +import { getMessageTextLength } from '../../shared/utils/paste-attachment' +import { + appendUserMessageToChat, + createChatWithMessage, + persistAnonymousTitle, + readAnonymousTitle, + titleFromParts +} from '../../shared/utils/chat' +import { + clearNavigationChatId, + consumeFreshChat, + patchChatDetailCache, + seedChatDetailCache, + uiMessagesToRows +} from './useChatDetail' +import { eveMessagesToUIMessages } from './eve/adapter' +import { useEveChat } from './eve/useEveChat' +import { usePasteAttachment } from './usePasteAttachment' + +export type NuxiChatSendInput = string | { parts: UIMessage['parts'], persist?: boolean } + +export interface UseNuxiChatOptions { + chatId: MaybeRefOrGetter + initialMessages?: MaybeRefOrGetter + initialState?: MaybeRefOrGetter + source: string + withPageContext?: 'always' | 'when-enabled' + fetchVotes?: MaybeRefOrGetter + onFinish?: () => void + onTitle?: (title: string) => void + data?: Ref + dataStatus?: Ref + isOwner?: Ref + consumePendingPrompt?: () => string | null + consumePendingMessageParts?: () => UIMessage['parts'] | null + onAnonymousTitle?: (parts: UIMessage['parts']) => void + redirectIfAnonymousEmpty?: () => void +} + +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 +} + +async function syncChatToDb( + id: string, + snapshot: UseEveAgentSnapshot, + messages: UIMessage[] +) { + if (!snapshot.events.length) return + + const state: ChatEveState = { + session: { + sessionId: snapshot.session.sessionId, + continuationToken: snapshot.session.continuationToken ?? id, + streamIndex: snapshot.events.length + }, + events: [...snapshot.events] + } + + await $fetch(`/api/chats/${id}/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 { + const id = options.chatId() + await syncChatToDb(id, snapshot, messages) + patchChatDetailCache(id, { + state: { + session: { + sessionId: snapshot.session.sessionId, + continuationToken: snapshot.session.continuationToken ?? id, + streamIndex: snapshot.events.length + }, + events: [...snapshot.events] + }, + messages: uiMessagesToRows(messages) + }) + await options.refreshChats?.() + + let generatedTitle = options.findChatTitle?.(id) ?? 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/${id}/title`, { + method: 'PATCH', + body: { title: fallback } + }) + generatedTitle = fallback + } + } + + if (generatedTitle) { + options.patchTitle?.(id, 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: MaybeRefOrGetter, + agent: ReturnType, + withPageContext: ComputedRef +) { + return () => { + const headers: Record = { + 'x-nuxi-chat-id': toValue(chatId) + } + + if (withPageContext.value && agent.currentPage.value) { + headers['x-page-path'] = agent.currentPage.value + } + + return headers + } +} + +function useChatVotes(chatId: MaybeRefOrGetter, fetchVotes: MaybeRefOrGetter = false) { + const toast = useToast() + const chatIdValue = computed(() => toValue(chatId)) + const shouldFetch = computed(() => toValue(fetchVotes)) + + const { data: voteRows, execute } = useLazyFetch( + () => `/api/chats/${chatIdValue.value}/votes`, + { + immediate: false, + default: () => [] as ChatVoteRow[] + } + ) + + watch(shouldFetch, (next) => { + if (next) void execute() + }, { immediate: true }) + + const votes = ref(new Map()) + + watch(voteRows, (rows) => { + const map = new Map() + for (const row of (rows ?? []) as ChatVoteRow[]) { + map.set(row.messageId, row.isUpvoted) + } + votes.value = map + }, { immediate: true }) + + function getRows(): ChatVoteRow[] { + return (voteRows.value ?? []) as ChatVoteRow[] + } + + function getVote(messageId: string): boolean | null { + const vote = votes.value.get(messageId) + return vote === undefined ? null : vote + } + + function vote(message: UIMessage, isUpvoted: boolean) { + const current = votes.value.get(message.id) + const next = current === isUpvoted ? undefined : isUpvoted + const snapshot = new Map(votes.value) + + if (next === undefined) votes.value.delete(message.id) + else votes.value.set(message.id, next) + votes.value = new Map(votes.value) + + voteRows.value = next === undefined + ? getRows().filter(row => row.messageId !== message.id) + : [ + ...getRows().filter(row => row.messageId !== message.id), + { chatId: chatIdValue.value, messageId: message.id, isUpvoted: next } + ] + + $fetch(`/api/chats/${chatIdValue.value}/votes`, { + method: 'POST', + body: next === undefined + ? { messageId: message.id } + : { messageId: message.id, isUpvoted: next } + }).catch(() => { + votes.value = snapshot + voteRows.value = [...snapshot.entries()].map(([messageId, isUpvoted]) => ({ + chatId: chatIdValue.value, + messageId, + isUpvoted + })) + toast.add({ description: 'Failed to save vote', icon: 'i-lucide-alert-circle', color: 'error' }) + }) + } + + return { votes, getVote, vote } +} + +function needsGeneration(messages: ChatMessageRow[]) { + const last = messages[messages.length - 1] + return last?.role === 'user' +} + +export function chatDetailForResume( + chatId: string, + initialMessages: UIMessage[] | undefined, + initialState: ChatEveState | null | undefined, + fetched?: ChatDetail | null +): ChatDetail | undefined { + if (fetched) return fetched + if (!initialMessages?.length) return undefined + + const rows: ChatMessageRow[] = uiMessagesToRows(initialMessages) + + if (!needsGeneration(rows)) return undefined + + return { + id: chatId, + title: null, + visibility: 'private', + isOwner: true, + createdAt: new Date().toISOString(), + state: initialState ?? null, + messages: rows + } +} + +export function setupStaleChatRefresh(options: { + chatId: MaybeRefOrGetter + data: Ref + status: Ref + refresh: () => Promise +}) { + onMounted(() => { + const id = toValue(options.chatId) + if (!id) return + if (consumeFreshChat(id)) 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 + 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'], persist: false }) + } + 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 useNuxiChat(options: UseNuxiChatOptions) { + const agent = useNuxtAgent() + const chats = useChats() + const { loggedIn } = useUserSession() + const { track } = useAnalytics() + const toast = useToast() + const chatId = computed(() => toValue(options.chatId)) + + const input = ref('') + const paste = usePasteAttachment(input) + const { getVote, vote } = useChatVotes(chatId, () => toValue(options.fetchVotes) ?? false) + + const chatExistsInDb = ref((toValue(options.initialMessages)?.length ?? 0) > 0) + + watch( + () => toValue(options.initialMessages)?.length ?? 0, + (length) => { + if (length > 0) chatExistsInDb.value = true + } + ) + + const useContext = computed(() => + options.withPageContext === 'always' + ? Boolean(agent.currentPage.value) + : agent.pageContextEnabled.value && Boolean(agent.currentPage.value) + ) + + const eveChat = useEveChat({ + chatId: options.chatId, + initialMessages: options.initialMessages, + initialState: options.initialState, + headers: buildEveHeaders(options.chatId, agent, useContext), + onFinish: createChatSyncHandler({ + chatId: () => chatId.value, + 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: options.onTitle, + onFinish: options.onFinish + }) + }) + + const chat = { + get messages() { + return eveChat.messages + }, + get status() { + return eveChat.status + }, + get error() { + return eveChat.error + }, + stop: eveChat.stop, + regenerate: eveChat.regenerate + } + + async function persistUserMessage(parts: UIMessage['parts']) { + const metadata = { + createdAt: new Date().toISOString(), + ...(useContext.value && agent.currentPage.value ? { pagePath: agent.currentPage.value } : {}) + } + + if (!chatExistsInDb.value) { + const detail = await createChatWithMessage(chatId.value, parts, metadata) + seedChatDetailCache(chatId.value, detail) + chatExistsInDb.value = true + return + } + + await appendUserMessageToChat(chatId.value, parts, metadata) + } + + async function send(inputValue: NuxiChatSendInput) { + const parts = typeof inputValue === 'string' + ? [{ type: 'text' as const, text: inputValue }] + : inputValue.parts + const persist = typeof inputValue === 'string' || inputValue.persist !== false + + if (!parts.length || getMessageTextLength(parts) === 0 || agent.rateLimitReached.value) return + + track('Nuxi Message Sent', { + source: options.source, + page: agent.currentPage.value, + withContext: useContext.value, + queryLength: getMessageTextLength(parts) + }) + + if (loggedIn.value && persist) { + try { + await persistUserMessage(parts) + } catch { + toast.add({ + description: 'Failed to save message', + icon: 'i-lucide-alert-circle', + color: 'error' + }) + } + } + + await eveChat.send(typeof inputValue === 'string' ? inputValue : { parts }) + agent.onMessageSent() + } + + async function onSubmit() { + if (!paste.canSubmit.value) return + const parts = paste.buildMessageParts() + input.value = '' + paste.clearAttachments() + await send({ parts }) + } + + function askQuestion(question: string) { + track('Nuxi FAQ Clicked', { question, source: options.source }) + send(question) + } + + const isOwner = options.isOwner ?? computed(() => loggedIn.value) + const resumeDone = ref(false) + + const resumeData = computed(() => chatDetailForResume( + chatId.value, + toValue(options.initialMessages), + toValue(options.initialState), + options.data?.value + )) + + watch( + [() => options.data?.value, () => options.dataStatus?.value, loggedIn, chatId], + () => { + if (resumeDone.value) return + if (!chatId.value) return + if (loggedIn.value && options.dataStatus?.value === 'pending') return + + resumeDone.value = true + resumeChatSession({ + chat, + send, + hasAgentUser: () => eveChat.hasAgentMessage('user'), + data: options.data ?? resumeData, + loggedIn, + isOwner, + consumePendingPrompt: options.consumePendingPrompt ?? (() => null), + consumePendingMessageParts: options.consumePendingMessageParts ?? (() => null), + onAnonymousTitle: options.onAnonymousTitle, + redirectIfAnonymousEmpty: options.redirectIfAnonymousEmpty + }) + clearNavigationChatId() + }, + { immediate: true } + ) + + return { + chat, + input, + prompt: paste.prompt, + getVote, + vote, + send, + onSubmit, + askQuestion, + handlePaste: paste.handlePaste, + removeAttachment: paste.removeAttachment, + restoreToInput: paste.restoreToInput, + hasAgentUser: () => eveChat.hasAgentMessage('user'), + readAnonymousTitle: () => readAnonymousTitle(chatId.value) + } +} 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..6c43cdbb6 --- /dev/null +++ b/layers/nuxi/app/composables/useStartChat.ts @@ -0,0 +1,71 @@ +import type { UIMessage } from 'ai' +import { buildMessageParts, getMessageTextLength } from '../../shared/utils/paste-attachment' +import { createChatWithMessage } from '../../shared/utils/chat' +import { markChatAsFresh, seedChatDetailCache, setNavigationChatId } from './useChatDetail' +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']): Promise { + if (loading.value || agent.rateLimitReached.value || getMessageTextLength(parts) === 0) return false + loading.value = true + + try { + if (loggedIn.value) { + const chatId = crypto.randomUUID() + const detail = await createChatWithMessage(chatId, parts) + seedChatDetailCache(chatId, detail) + markChatAsFresh(chatId) + setNavigationChatId(chatId) + await navigateTo(`/dashboard/chat/${chatId}`) + void chats.refresh() + } else { + const chatId = crypto.randomUUID() + setNavigationChatId(chatId) + agent.pendingMessageParts.value = parts + await navigateTo(`/dashboard/chat/${chatId}`) + } + return true + } catch { + toast.add({ description: 'Failed to create chat', icon: 'i-lucide-alert-circle', color: 'error' }) + return false + } finally { + loading.value = false + } + } + + async function onSubmit() { + if (!paste.canSubmit.value) return + const parts = paste.buildMessageParts() + track('Nuxi Message Sent', { source, queryLength: getMessageTextLength(parts) }) + if (await createChat(parts)) { + paste.clearAttachments() + input.value = '' + } + } + + 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 3f8fb8c84..8b4327a28 100644 --- a/layers/nuxi/app/pages/dashboard/chat/[id].vue +++ b/layers/nuxi/app/pages/dashboard/chat/[id].vue @@ -1,104 +1,83 @@ @@ -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/app/plugins/chat-view-transitions.client.ts b/layers/nuxi/app/plugins/chat-view-transitions.client.ts new file mode 100644 index 000000000..e2fcc7e92 --- /dev/null +++ b/layers/nuxi/app/plugins/chat-view-transitions.client.ts @@ -0,0 +1,101 @@ +/** + * Scoped View Transitions for dashboard chat only. + * Morphs the prompt between /dashboard/chat and /dashboard/chat/:id. + */ +export default defineNuxtPlugin((nuxtApp) => { + if (!document.startViewTransition) return + + let transition: ViewTransition | undefined + let finishTransition: (() => void) | undefined + let hasUAVisualTransition = false + + const resetTransitionState = (active?: ViewTransition) => { + if (active && transition !== active) return + transition = undefined + finishTransition = undefined + hasUAVisualTransition = false + } + + window.addEventListener('popstate', (event) => { + hasUAVisualTransition + = (event as PopStateEvent & { hasUAVisualTransition?: boolean }).hasUAVisualTransition ?? false + if (hasUAVisualTransition) { + transition?.skipTransition() + } + }) + + const router = useRouter() + + router.beforeResolve(async (to, from) => { + if (to.matched.length === 0) return + + if (!isChatPromptTransition(to.path, from.path)) return + + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return + if (hasUAVisualTransition) return + + if (transition) { + transition.skipTransition() + finishTransition?.() + resetTransitionState(transition) + } + + const promise = new Promise((resolve) => { + finishTransition = resolve + }) + + let changeRoute: () => void + const ready = new Promise(resolve => (changeRoute = resolve)) + + const activeTransition = document.startViewTransition(() => { + changeRoute!() + return promise + }) + transition = activeTransition + + activeTransition.ready.catch(handleViewTransitionRejection) + activeTransition.updateCallbackDone.catch(handleViewTransitionRejection) + activeTransition.finished.catch(handleViewTransitionRejection).finally(() => resetTransitionState(activeTransition)) + + await nuxtApp.callHook('page:view-transition:start', activeTransition) + + return ready + }) + + router.onError(() => { + finishTransition?.() + resetTransitionState(transition) + }) + nuxtApp.hook('app:error', () => { + finishTransition?.() + resetTransitionState(transition) + }) + nuxtApp.hook('vue:error', () => { + finishTransition?.() + resetTransitionState(transition) + }) + + nuxtApp.hook('page:finish', () => { + finishTransition?.() + resetTransitionState(transition) + }) +}) + +function isChatPromptTransition(toPath: string, fromPath: string) { + const toChat = /^\/dashboard\/chat\/[^/]+$/.test(toPath) + const fromHome = fromPath === '/dashboard/chat' + const fromChat = /^\/dashboard\/chat\/[^/]+$/.test(fromPath) + const toHome = toPath === '/dashboard/chat' + return (fromHome && toChat) || (fromChat && toHome) +} + +function handleViewTransitionRejection(error: unknown) { + if (!import.meta.dev || isExpectedViewTransitionRejection(error)) return + console.warn('[chat-view-transitions] transition promise rejected', error) +} + +function isExpectedViewTransitionRejection(error: unknown) { + const name = error instanceof Error ? error.name : '' + const message = error instanceof Error ? error.message : String(error) + return name === 'AbortError' || message.includes('Transition was aborted') +} diff --git a/layers/nuxi/server/api/chats/index.post.ts b/layers/nuxi/server/api/chats/index.post.ts index 865e93682..37be6fa3c 100644 --- a/layers/nuxi/server/api/chats/index.post.ts +++ b/layers/nuxi/server/api/chats/index.post.ts @@ -1,6 +1,7 @@ import { createError } from 'h3' import type { ExtractTablesWithRelations } from 'drizzle-orm' import type { LibSQLTransaction } from 'drizzle-orm/libsql' +import { asc, eq } from 'drizzle-orm' import { z } from 'zod' type Tx = LibSQLTransaction> @@ -23,7 +24,7 @@ export default defineEventHandler(async (event) => { chat: { id } }) - const chat = await db.transaction(async (tx: Tx) => { + await db.transaction(async (tx: Tx) => { const existing = await tx.query.chats.findFirst({ where: () => eq(schema.chats.id, id) }) @@ -67,5 +68,19 @@ export default defineEventHandler(async (event) => { return row }) - return chat + const detail = await db.query.chats.findFirst({ + where: () => eq(schema.chats.id, id), + with: { + messages: { + orderBy: () => [asc(schema.messages.createdAt), asc(schema.messages.id)] + } + } + }) + + if (!detail) { + throw createError({ statusCode: 500, statusMessage: 'Failed to load created chat' }) + } + + const { userId: _, ...rest } = detail + return { ...rest, isOwner: true } }) diff --git a/layers/nuxi/shared/utils/chat.ts b/layers/nuxi/shared/utils/chat.ts index b9029a1ec..0bbbab8e5 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, @@ -35,7 +35,7 @@ export async function createChatWithMessage( chatId: string, parts: UIMessage['parts'], metadata: Record = {} -): Promise { +): Promise { const userMessage: UIMessage = { id: crypto.randomUUID(), role: 'user', @@ -46,12 +46,12 @@ export async function createChatWithMessage( } } - await $fetch('/api/chats', { + const detail = await $fetch('/api/chats', { method: 'POST', body: { id: chatId, message: userMessage } }) - return userMessage + return detail } export async function appendUserMessageToChat( @@ -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}`) +} diff --git a/nuxt.config.ts b/nuxt.config.ts index bdf46880d..bdaff8add 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -453,7 +453,6 @@ export default defineNuxtConfig({ sourcemap: true, experimental: { extractAsyncDataHandlers: true, - viewTransition: true, defaults: { nuxtLink: { externalRelAttribute: 'noopener'