diff --git a/app/pages/chat/[id].vue b/app/pages/chat/[id].vue index fb618cda..05b5c3f0 100644 --- a/app/pages/chat/[id].vue +++ b/app/pages/chat/[id].vue @@ -48,16 +48,6 @@ const { messages, status, error, sendMessage, regenerate, stop } = useChat({ model: model.value } }), - onData: async (dataPart) => { - if (dataPart.type === 'data-chat-title') { - await refreshNuxtData('chats') - const chatsCache = useNuxtData<{ id: string, label: string }[]>('chats') - const updated = chatsCache.data.value?.find(c => c.id === data.value!.id) - if (updated && updated.label !== 'Untitled') { - title.value = updated.label - } - } - }, onError(error) { let message = error.message if (typeof message === 'string' && message[0] === '{') { @@ -77,6 +67,20 @@ const { messages, status, error, sendMessage, regenerate, stop } = useChat({ } }) +// The title is generated server-side (and persisted) before streaming starts on +// the first message; there's no in-stream signal, so refresh the sidebar/title as +// soon as the response begins streaming. +watch(status, async (value) => { + if (value !== 'streaming' || title.value || !isOwner.value) return + + await refreshNuxtData('chats') + const chatsCache = useNuxtData<{ id: string, label: string }[]>('chats') + const updated = chatsCache.data.value?.find(c => c.id === data.value!.id) + if (updated && updated.label !== 'Untitled') { + title.value = updated.label + } +}) + async function handleSubmit(e: Event) { e.preventDefault() if (input.value.trim() && !uploading.value) { diff --git a/server/api/chats/[id].post.ts b/server/api/chats/[id].post.ts index ba5e5654..56d6b9f2 100644 --- a/server/api/chats/[id].post.ts +++ b/server/api/chats/[id].post.ts @@ -1,5 +1,5 @@ import type { UIMessage } from 'ai' -import { convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse, generateText, isStepCount, smoothStream, streamText, toUIMessageStream } from 'ai' +import { convertToModelMessages, createUIMessageStreamResponse, generateText, isStepCount, smoothStream, streamText, toUIMessageStream } from 'ai' import { db, schema } from 'hub:db' import { and, eq } from 'drizzle-orm' import { z } from 'zod' @@ -72,12 +72,10 @@ export default defineEventHandler(async (event) => { const abortController = new AbortController() event.node.req.on('close', () => abortController.abort()) - const stream = createUIMessageStream({ - execute: async ({ writer }) => { - const result = streamText({ - abortSignal: abortController.signal, - model, - instructions: `You are a knowledgeable and helpful AI assistant. ${session.user?.username ? `The user's name is ${session.user.username}.` : ''} Your goal is to provide clear, accurate, and well-structured responses. + const result = streamText({ + abortSignal: abortController.signal, + model, + instructions: `You are a knowledgeable and helpful AI assistant. ${session.user?.username ? `The user's name is ${session.user.username}.` : ''} Your goal is to provide clear, accurate, and well-structured responses. **FORMATTING RULES (CRITICAL):** - ABSOLUTELY NO MARKDOWN HEADINGS: Never use #, ##, ###, ####, #####, or ###### @@ -99,58 +97,52 @@ export default defineEventHandler(async (event) => { - Use examples when helpful - Break down complex topics into digestible parts - Maintain a friendly, professional tone`, - messages: await convertToModelMessages(messages), - tools: { - chart: chartTool, - weather: weatherTool, - ...(model.startsWith('anthropic/') && { web_search: anthropic.tools.webSearch_20250305() }), - ...(model.startsWith('openai/') && { web_search: openai.tools.webSearch() }) - // TODO: enable once AI SDK supports combining provider-defined tools with custom tools - // ...(model.startsWith('google/') && { google_search: google.tools.googleSearch({}) }) - }, - providerOptions: { - anthropic: { - thinking: { - type: 'enabled', - budgetTokens: 2048 - } - } satisfies AnthropicLanguageModelOptions, - google: { - thinkingConfig: { - includeThoughts: true, - thinkingLevel: 'low' - } - } satisfies GoogleLanguageModelOptions, - openai: { - reasoningEffort: 'low', - reasoningSummary: 'detailed' - } satisfies OpenAILanguageModelResponsesOptions - }, - stopWhen: isStepCount(5), - experimental_transform: smoothStream() - }) - - if (!chat.title) { - writer.write({ - type: 'data-chat-title', - data: { message: 'Generating title...' }, - transient: true - }) - } - - writer.merge(toUIMessageStream({ - stream: result.stream, - sendSources: true, - sendReasoning: true - })) + messages: await convertToModelMessages(messages), + tools: { + chart: chartTool, + weather: weatherTool, + ...(model.startsWith('anthropic/') && { web_search: anthropic.tools.webSearch_20250305() }), + ...(model.startsWith('openai/') && { web_search: openai.tools.webSearch() }) + // TODO: enable once AI SDK supports combining provider-defined tools with custom tools + // ...(model.startsWith('google/') && { google_search: google.tools.googleSearch({}) }) }, - onEnd: async ({ messages }) => { - await db.insert(schema.messages).values(messages.map(message => ({ - id: message.id, + providerOptions: { + anthropic: { + thinking: { + type: 'enabled', + budgetTokens: 2048 + } + } satisfies AnthropicLanguageModelOptions, + google: { + thinkingConfig: { + includeThoughts: true, + thinkingLevel: 'low' + } + } satisfies GoogleLanguageModelOptions, + openai: { + reasoningEffort: 'low', + reasoningSummary: 'detailed' + } satisfies OpenAILanguageModelResponsesOptions + }, + stopWhen: isStepCount(5), + experimental_transform: smoothStream() + }) + + const stream = toUIMessageStream({ + stream: result.stream, + sendSources: true, + sendReasoning: true, + onEnd: async ({ responseMessage }) => { + // Don't persist an empty assistant message (e.g. the stream was aborted + // before any content), otherwise it reloads as an empty frame. + if (!responseMessage.parts?.length) return + + await db.insert(schema.messages).values([{ + id: responseMessage.id, chatId: chat.id, - role: message.role as 'user' | 'assistant', - parts: message.parts - }))).onConflictDoNothing() + role: responseMessage.role as 'user' | 'assistant', + parts: responseMessage.parts + }]).onConflictDoNothing() } })