diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 9bbae17..6e18c3e 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -13,6 +13,7 @@ jobs: AUTH_SECRET: ${{ secrets.AUTH_SECRET }} POSTGRES_URL: ${{ secrets.POSTGRES_URL }} BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }} + REDIS_URL: ${{ secrets.REDIS_URL }} steps: - uses: actions/checkout@v4 diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 9b0a396..3b212ee 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -1,17 +1,19 @@ import { appendClientMessage, appendResponseMessages, - createDataStreamResponse, + createDataStream, smoothStream, streamText, } from 'ai'; import { auth, type UserType } from '@/app/(auth)/auth'; import { type RequestHints, systemPrompt } from '@/lib/ai/prompts'; import { + createStreamId, deleteChatById, getChatById, getMessageCountByUserId, getMessagesByChatId, + getStreamIdsByChatId, saveChat, saveMessages, } from '@/lib/db/queries'; @@ -26,9 +28,16 @@ import { myProvider } from '@/lib/ai/providers'; import { entitlementsByUserType } from '@/lib/ai/entitlements'; import { postRequestBodySchema, type PostRequestBody } from './schema'; import { geolocation } from '@vercel/functions'; +import { createResumableStreamContext } from 'resumable-stream'; +import { after } from 'next/server'; +import type { Chat } from '@/lib/db/schema'; export const maxDuration = 60; +const streamContext = createResumableStreamContext({ + waitUntil: after, +}); + export async function POST(request: Request) { let requestBody: PostRequestBody; @@ -108,7 +117,10 @@ export async function POST(request: Request) { ], }); - return createDataStreamResponse({ + const streamId = generateUUID(); + await createStreamId({ streamId, chatId: id }); + + const stream = createDataStream({ execute: (dataStream) => { const result = streamText({ model: myProvider.languageModel(selectedChatModel), @@ -187,6 +199,10 @@ export async function POST(request: Request) { return 'Oops, an error occurred!'; }, }); + + return new Response( + await streamContext.resumableStream(streamId, () => stream), + ); } catch (_) { return new Response('An error occurred while processing your request!', { status: 500, @@ -194,6 +210,60 @@ export async function POST(request: Request) { } } +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const chatId = searchParams.get('chatId'); + + if (!chatId) { + return new Response('id is required', { status: 400 }); + } + + const session = await auth(); + + if (!session?.user) { + return new Response('Unauthorized', { status: 401 }); + } + + let chat: Chat; + + try { + chat = await getChatById({ id: chatId }); + } catch { + return new Response('Not found', { status: 404 }); + } + + if (!chat) { + return new Response('Not found', { status: 404 }); + } + + if (chat.userId !== session.user.id) { + return new Response('Forbidden', { status: 403 }); + } + + const streamIds = await getStreamIdsByChatId({ chatId }); + + if (!streamIds.length) { + return new Response('No streams found', { status: 404 }); + } + + const recentStreamId = streamIds.at(-1); + + if (!recentStreamId) { + return new Response('No recent stream found', { status: 404 }); + } + + const emptyDataStream = createDataStream({ + execute: () => {}, + }); + + return new Response( + await streamContext.resumableStream(recentStreamId, () => emptyDataStream), + { + status: 200, + }, + ); +} + export async function DELETE(request: Request) { const { searchParams } = new URL(request.url); const id = searchParams.get('id'); @@ -219,6 +289,7 @@ export async function DELETE(request: Request) { return Response.json(deletedChat, { status: 200 }); } catch (error) { + console.error(error); return new Response('An error occurred while processing your request!', { status: 500, }); diff --git a/app/(chat)/chat/[id]/page.tsx b/app/(chat)/chat/[id]/page.tsx index 8af21d0..9f3c3d8 100644 --- a/app/(chat)/chat/[id]/page.tsx +++ b/app/(chat)/chat/[id]/page.tsx @@ -64,6 +64,7 @@ export default async function Page(props: { params: Promise<{ id: string }> }) { selectedVisibilityType={chat.visibility} isReadonly={session?.user?.id !== chat.userId} session={session} + autoResume={true} /> @@ -79,6 +80,7 @@ export default async function Page(props: { params: Promise<{ id: string }> }) { selectedVisibilityType={chat.visibility} isReadonly={session?.user?.id !== chat.userId} session={session} + autoResume={true} /> diff --git a/app/(chat)/page.tsx b/app/(chat)/page.tsx index 09ff2e6..9516b57 100644 --- a/app/(chat)/page.tsx +++ b/app/(chat)/page.tsx @@ -30,6 +30,7 @@ export default async function Page() { selectedVisibilityType="private" isReadonly={false} session={session} + autoResume={false} /> @@ -46,6 +47,7 @@ export default async function Page() { selectedVisibilityType="private" isReadonly={false} session={session} + autoResume={false} /> diff --git a/components/chat.tsx b/components/chat.tsx index 045d61c..9310882 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -25,6 +25,7 @@ export function Chat({ selectedVisibilityType, isReadonly, session, + autoResume, }: { id: string; initialMessages: Array; @@ -32,6 +33,7 @@ export function Chat({ selectedVisibilityType: VisibilityType; isReadonly: boolean; session: Session; + autoResume: boolean; }) { const { mutate } = useSWRConfig(); @@ -45,6 +47,7 @@ export function Chat({ status, stop, reload, + experimental_resume, } = useChat({ id, initialMessages, @@ -67,6 +70,15 @@ export function Chat({ }, }); + useEffect(() => { + if (autoResume) { + experimental_resume(); + } + + // note: this hook has no dependencies since it only needs to run once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const searchParams = useSearchParams(); const query = searchParams.get('query'); diff --git a/lib/db/migrations/0006_marvelous_frog_thor.sql b/lib/db/migrations/0006_marvelous_frog_thor.sql new file mode 100644 index 0000000..f82107b --- /dev/null +++ b/lib/db/migrations/0006_marvelous_frog_thor.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS "Stream" ( + "id" uuid DEFAULT gen_random_uuid() NOT NULL, + "chatId" uuid NOT NULL, + "createdAt" timestamp NOT NULL, + CONSTRAINT "Stream_id_pk" PRIMARY KEY("id") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "Stream" ADD CONSTRAINT "Stream_chatId_Chat_id_fk" FOREIGN KEY ("chatId") REFERENCES "public"."Chat"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/lib/db/migrations/meta/0006_snapshot.json b/lib/db/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..69bd955 --- /dev/null +++ b/lib/db/migrations/meta/0006_snapshot.json @@ -0,0 +1,565 @@ +{ + "id": "443de550-b7e8-4bfb-b229-c12dc6c132f0", + "prevId": "c6c102e6-b64e-4f0c-a7a6-32df758de437", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.Chat": { + "name": "Chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'private'" + } + }, + "indexes": {}, + "foreignKeys": { + "Chat_userId_User_id_fk": { + "name": "Chat_userId_User_id_fk", + "tableFrom": "Chat", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Document": { + "name": "Document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Document_userId_User_id_fk": { + "name": "Document_userId_User_id_fk", + "tableFrom": "Document", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "Document_id_createdAt_pk": { + "name": "Document_id_createdAt_pk", + "columns": [ + "id", + "createdAt" + ] + } + }, + "uniqueConstraints": {} + }, + "public.Message_v2": { + "name": "Message_v2", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chatId": { + "name": "chatId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "parts": { + "name": "parts", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "attachments": { + "name": "attachments", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Message_v2_chatId_Chat_id_fk": { + "name": "Message_v2_chatId_Chat_id_fk", + "tableFrom": "Message_v2", + "tableTo": "Chat", + "columnsFrom": [ + "chatId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Message": { + "name": "Message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chatId": { + "name": "chatId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Message_chatId_Chat_id_fk": { + "name": "Message_chatId_Chat_id_fk", + "tableFrom": "Message", + "tableTo": "Chat", + "columnsFrom": [ + "chatId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Stream": { + "name": "Stream", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chatId": { + "name": "chatId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Stream_chatId_Chat_id_fk": { + "name": "Stream_chatId_Chat_id_fk", + "tableFrom": "Stream", + "tableTo": "Chat", + "columnsFrom": [ + "chatId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "Stream_id_pk": { + "name": "Stream_id_pk", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.Suggestion": { + "name": "Suggestion", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "documentId": { + "name": "documentId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "documentCreatedAt": { + "name": "documentCreatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "originalText": { + "name": "originalText", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "suggestedText": { + "name": "suggestedText", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isResolved": { + "name": "isResolved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Suggestion_userId_User_id_fk": { + "name": "Suggestion_userId_User_id_fk", + "tableFrom": "Suggestion", + "tableTo": "User", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Suggestion_documentId_documentCreatedAt_Document_id_createdAt_fk": { + "name": "Suggestion_documentId_documentCreatedAt_Document_id_createdAt_fk", + "tableFrom": "Suggestion", + "tableTo": "Document", + "columnsFrom": [ + "documentId", + "documentCreatedAt" + ], + "columnsTo": [ + "id", + "createdAt" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "Suggestion_id_pk": { + "name": "Suggestion_id_pk", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.User": { + "name": "User", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.Vote_v2": { + "name": "Vote_v2", + "schema": "", + "columns": { + "chatId": { + "name": "chatId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "messageId": { + "name": "messageId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "isUpvoted": { + "name": "isUpvoted", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Vote_v2_chatId_Chat_id_fk": { + "name": "Vote_v2_chatId_Chat_id_fk", + "tableFrom": "Vote_v2", + "tableTo": "Chat", + "columnsFrom": [ + "chatId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Vote_v2_messageId_Message_v2_id_fk": { + "name": "Vote_v2_messageId_Message_v2_id_fk", + "tableFrom": "Vote_v2", + "tableTo": "Message_v2", + "columnsFrom": [ + "messageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "Vote_v2_chatId_messageId_pk": { + "name": "Vote_v2_chatId_messageId_pk", + "columns": [ + "chatId", + "messageId" + ] + } + }, + "uniqueConstraints": {} + }, + "public.Vote": { + "name": "Vote", + "schema": "", + "columns": { + "chatId": { + "name": "chatId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "messageId": { + "name": "messageId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "isUpvoted": { + "name": "isUpvoted", + "type": "boolean", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "Vote_chatId_Chat_id_fk": { + "name": "Vote_chatId_Chat_id_fk", + "tableFrom": "Vote", + "tableTo": "Chat", + "columnsFrom": [ + "chatId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "Vote_messageId_Message_id_fk": { + "name": "Vote_messageId_Message_id_fk", + "tableFrom": "Vote", + "tableTo": "Message", + "columnsFrom": [ + "messageId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "Vote_chatId_messageId_pk": { + "name": "Vote_chatId_messageId_pk", + "columns": [ + "chatId", + "messageId" + ] + } + }, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json index b238865..f7b8875 100644 --- a/lib/db/migrations/meta/_journal.json +++ b/lib/db/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1741934630596, "tag": "0005_wooden_whistler", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1746118166211, + "tag": "0006_marvelous_frog_thor", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 7f812ba..14de5a3 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -26,6 +26,7 @@ import { vote, type DBMessage, type Chat, + stream, } from './schema'; import type { ArtifactKind } from '@/components/artifact'; import { generateUUID } from '../utils'; @@ -100,6 +101,7 @@ export async function deleteChatById({ id }: { id: string }) { try { await db.delete(vote).where(eq(vote.chatId, id)); await db.delete(message).where(eq(message.chatId, id)); + await db.delete(stream).where(eq(stream.chatId, id)); const [chatsDeleted] = await db .delete(chat) @@ -470,3 +472,36 @@ export async function getMessageCountByUserId({ throw error; } } + +export async function createStreamId({ + streamId, + chatId, +}: { + streamId: string; + chatId: string; +}) { + try { + await db + .insert(stream) + .values({ id: streamId, chatId, createdAt: new Date() }); + } catch (error) { + console.error('Failed to create stream id in database'); + throw error; + } +} + +export async function getStreamIdsByChatId({ chatId }: { chatId: string }) { + try { + const streamIds = await db + .select({ id: stream.id }) + .from(stream) + .where(eq(stream.chatId, chatId)) + .orderBy(desc(stream.createdAt)) + .execute(); + + return streamIds.map(({ id }) => id); + } catch (error) { + console.error('Failed to get stream ids by chat id from database'); + throw error; + } +} diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 1228aab..6de4559 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -150,3 +150,21 @@ export const suggestion = pgTable( ); export type Suggestion = InferSelectModel; + +export const stream = pgTable( + 'Stream', + { + id: uuid('id').notNull().defaultRandom(), + chatId: uuid('chatId').notNull(), + createdAt: timestamp('createdAt').notNull(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.id] }), + chatRef: foreignKey({ + columns: [table.chatId], + foreignColumns: [chat.id], + }), + }), +); + +export type Stream = InferSelectModel; diff --git a/package.json b/package.json index d7b84d1..6806725 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ai-chatbot", - "version": "3.0.15", + "version": "3.0.16", "private": true, "scripts": { "dev": "next dev --turbo", @@ -19,8 +19,8 @@ "test": "export PLAYWRIGHT=True && pnpm exec playwright test" }, "dependencies": { - "@ai-sdk/react": "^1.2.8", - "@ai-sdk/xai": "^1.2.10", + "@ai-sdk/react": "^1.2.11", + "@ai-sdk/xai": "^1.2.15", "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-python": "^6.1.6", "@codemirror/state": "^6.5.0", @@ -40,7 +40,7 @@ "@vercel/blob": "^0.24.1", "@vercel/functions": "^2.0.0", "@vercel/postgres": "^0.10.0", - "ai": "4.3.4", + "ai": "4.3.13", "bcrypt-ts": "^5.0.2", "class-variance-authority": "^0.7.0", "classnames": "^2.5.1", @@ -74,7 +74,9 @@ "react-dom": "19.0.0-rc-45804af1-20241021", "react-markdown": "^9.0.1", "react-resizable-panels": "^2.1.7", + "redis": "^5.0.0", "remark-gfm": "^4.0.0", + "resumable-stream": "^2.0.0", "server-only": "^0.0.1", "sonner": "^1.5.0", "swr": "^2.2.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e34ff7..955afb2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,11 +9,11 @@ importers: .: dependencies: '@ai-sdk/react': - specifier: ^1.2.8 - version: 1.2.8(react@19.0.0-rc-45804af1-20241021)(zod@3.24.2) + specifier: ^1.2.11 + version: 1.2.11(react@19.0.0-rc-45804af1-20241021)(zod@3.24.2) '@ai-sdk/xai': - specifier: ^1.2.10 - version: 1.2.10(zod@3.24.2) + specifier: ^1.2.15 + version: 1.2.15(zod@3.24.2) '@codemirror/lang-javascript': specifier: ^6.2.2 version: 6.2.3 @@ -72,8 +72,8 @@ importers: specifier: ^0.10.0 version: 0.10.0 ai: - specifier: 4.3.4 - version: 4.3.4(react@19.0.0-rc-45804af1-20241021)(zod@3.24.2) + specifier: 4.3.13 + version: 4.3.13(react@19.0.0-rc-45804af1-20241021)(zod@3.24.2) bcrypt-ts: specifier: ^5.0.2 version: 5.0.3 @@ -173,9 +173,15 @@ importers: react-resizable-panels: specifier: ^2.1.7 version: 2.1.7(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021) + redis: + specifier: ^5.0.0 + version: 5.0.0 remark-gfm: specifier: ^4.0.0 version: 4.0.1 + resumable-stream: + specifier: ^2.0.0 + version: 2.0.0 server-only: specifier: ^0.0.1 version: 0.0.1 @@ -258,24 +264,24 @@ importers: packages: - '@ai-sdk/openai-compatible@0.2.8': - resolution: {integrity: sha512-o1CrhTrXnMj72G44oBqlDDBtunw6iwONysXj7EYN/Fx27rhP7YTcWwVeKs5gMx/u/8TIho9iZ9rkqXXc2xg8Bg==} + '@ai-sdk/openai-compatible@0.2.13': + resolution: {integrity: sha512-tB+lL8Z3j0qDod/mvxwjrPhbLUHp/aQW+NvMoJaqeTtP+Vmv5qR800pncGczxn5WN0pllQm+7aIRDnm69XeSbg==} engines: {node: '>=18'} peerDependencies: zod: ^3.0.0 - '@ai-sdk/provider-utils@2.2.6': - resolution: {integrity: sha512-sUlZ7Gnq84DCGWMQRIK8XVbkzIBnvPR1diV4v6JwPgpn5armnLI/j+rqn62MpLrU5ZCQZlDKl/Lw6ed3ulYqaA==} + '@ai-sdk/provider-utils@2.2.7': + resolution: {integrity: sha512-kM0xS3GWg3aMChh9zfeM+80vEZfXzR3JEUBdycZLtbRZ2TRT8xOj3WodGHPb06sUK5yD7pAXC/P7ctsi2fvUGQ==} engines: {node: '>=18'} peerDependencies: zod: ^3.23.8 - '@ai-sdk/provider@1.1.2': - resolution: {integrity: sha512-ITdgNilJZwLKR7X5TnUr1BsQW6UTX5yFp0h66Nfx8XjBYkWD9W3yugr50GOz3CnE9m/U/Cd5OyEbTMI0rgi6ZQ==} + '@ai-sdk/provider@1.1.3': + resolution: {integrity: sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==} engines: {node: '>=18'} - '@ai-sdk/react@1.2.8': - resolution: {integrity: sha512-S2FzCSi4uTF0JuSN6zYMXyiAWVAzi/Hho8ISYgHpGZiICYLNCP2si4DuXQOsnWef3IXzQPLVoE11C63lILZIkw==} + '@ai-sdk/react@1.2.11': + resolution: {integrity: sha512-+kPqLkJ3TWP6czaJPV+vzAKSUcKQ1598BUrcLHt56sH99+LhmIIW3ylZp0OfC3O6TR3eO1Lt0Yzw4R0mK6g9Gw==} engines: {node: '>=18'} peerDependencies: react: ^18 || ^19 || ^19.0.0-rc @@ -284,14 +290,14 @@ packages: zod: optional: true - '@ai-sdk/ui-utils@1.2.7': - resolution: {integrity: sha512-OVRxa4SDj0wVsMZ8tGr/whT89oqNtNoXBKmqWC2BRv5ZG6azL2LYZ5ZK35u3lb4l1IE7cWGsLlmq0py0ttsL7A==} + '@ai-sdk/ui-utils@1.2.10': + resolution: {integrity: sha512-GUj+LBoAlRQF1dL/M49jtufGqtLOMApxTpCmVjoRpIPt/dFALVL9RfqfvxwztyIwbK+IxGzcYjSGRsrWrj+86g==} engines: {node: '>=18'} peerDependencies: zod: ^3.23.8 - '@ai-sdk/xai@1.2.10': - resolution: {integrity: sha512-jPaOq7HHJ9A7FF3i/zngcUd6jczFMJF1x9Ayle4IxCZ2gIhtLgv0hiUb1X95keh/gCHjtelBQdzLFNNuiPBadQ==} + '@ai-sdk/xai@1.2.15': + resolution: {integrity: sha512-18qEYyVHIqTiOMePE00bfx4kJrTHM4dV3D3Rpe+eBISlY80X1FnzZRnRTJo3Q6MOSmW5+ZKVaX9jtryhoFpn0A==} engines: {node: '>=18'} peerDependencies: zod: ^3.0.0 @@ -1480,6 +1486,34 @@ packages: '@radix-ui/rect@1.1.0': resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==} + '@redis/bloom@5.0.0': + resolution: {integrity: sha512-YHlra6a7+brJ3Ustqa+jC68Pdnw2/wbnVLeULN0llP3gHpKxOSmiDyLwuHq5t/jFi6QUgjSprUQi2a8CieKFIA==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.0.0 + + '@redis/client@5.0.0': + resolution: {integrity: sha512-1MqfzOOoFBwWprigJ3S5hM+LuzaWwxyyx+74NDzCpmjKWyjjC07xkWsd5E9fr+61NUDtvUivRiyAxGiRIwJCaQ==} + engines: {node: '>= 18'} + + '@redis/json@5.0.0': + resolution: {integrity: sha512-8YFjshsvWR9dWjOiQ50TfBxfl+z1mjCae8jDnfOYN1aCvUZWJXqM1OpL56v6mWRyXwZ5C9qJJKDGmHTr4PVskQ==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.0.0 + + '@redis/search@5.0.0': + resolution: {integrity: sha512-oEop/S/0NB5p7vpTTlp5X5LQO4oTomJnFEOaKhQ5xZWROLXTgEVvPSfXVNBbtr9maU2+OupxXQR63HW3MGeTUg==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.0.0 + + '@redis/time-series@5.0.0': + resolution: {integrity: sha512-DNsP4DH5CRfXlQDyvLJm2DltZm3kjpVKZsNTI3SPdbRxhc9i2c0NehUbM/gDECnEfG+jkNN/LUv6Em4LaLwPBQ==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.0.0 + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -1655,8 +1689,8 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - ai@4.3.4: - resolution: {integrity: sha512-uMjzrowIqfU8CCCxhx8QGl7ETydHBROeNL0VoEwetkmDCY6Q8ZTacj6jNNqGJOiCk595aUrGR9VHPY9Ylvy1fg==} + ai@4.3.13: + resolution: {integrity: sha512-cC5HXItuOwGykSMacCPzNp6+NMTxeuTjOenztVgSJhdC9Z4OrzBxwkyeDAf4h1QP938ZFi7IBdq3u4lxVoVmvw==} engines: {node: '>=18'} peerDependencies: react: ^18 || ^19 || ^19.0.0-rc @@ -1865,6 +1899,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + codemirror@6.0.1: resolution: {integrity: sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==} @@ -3375,6 +3413,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + redis@5.0.0: + resolution: {integrity: sha512-J/fzf0cYeFw5NP4aYIvv9owYOcNUsaDqF4qmwbSuaV4yKBLaIHJQIFbAKLgjn99GDXdJBbfqCRXE7+BIlkpATA==} + engines: {node: '>= 18'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -3411,6 +3453,9 @@ packages: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true + resumable-stream@2.0.0: + resolution: {integrity: sha512-D7E0wDUnfoy+Lerba/gyuD44OG3G0APqDcQ9soMSerujaVujPLWc5sSCLXf/ZFQPreLb3MKDjSm3TOPXpNtpZw==} + retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -3870,45 +3915,45 @@ packages: snapshots: - '@ai-sdk/openai-compatible@0.2.8(zod@3.24.2)': + '@ai-sdk/openai-compatible@0.2.13(zod@3.24.2)': dependencies: - '@ai-sdk/provider': 1.1.2 - '@ai-sdk/provider-utils': 2.2.6(zod@3.24.2) + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.7(zod@3.24.2) zod: 3.24.2 - '@ai-sdk/provider-utils@2.2.6(zod@3.24.2)': + '@ai-sdk/provider-utils@2.2.7(zod@3.24.2)': dependencies: - '@ai-sdk/provider': 1.1.2 + '@ai-sdk/provider': 1.1.3 nanoid: 3.3.11 secure-json-parse: 2.7.0 zod: 3.24.2 - '@ai-sdk/provider@1.1.2': + '@ai-sdk/provider@1.1.3': dependencies: json-schema: 0.4.0 - '@ai-sdk/react@1.2.8(react@19.0.0-rc-45804af1-20241021)(zod@3.24.2)': + '@ai-sdk/react@1.2.11(react@19.0.0-rc-45804af1-20241021)(zod@3.24.2)': dependencies: - '@ai-sdk/provider-utils': 2.2.6(zod@3.24.2) - '@ai-sdk/ui-utils': 1.2.7(zod@3.24.2) + '@ai-sdk/provider-utils': 2.2.7(zod@3.24.2) + '@ai-sdk/ui-utils': 1.2.10(zod@3.24.2) react: 19.0.0-rc-45804af1-20241021 swr: 2.3.3(react@19.0.0-rc-45804af1-20241021) throttleit: 2.1.0 optionalDependencies: zod: 3.24.2 - '@ai-sdk/ui-utils@1.2.7(zod@3.24.2)': + '@ai-sdk/ui-utils@1.2.10(zod@3.24.2)': dependencies: - '@ai-sdk/provider': 1.1.2 - '@ai-sdk/provider-utils': 2.2.6(zod@3.24.2) + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.7(zod@3.24.2) zod: 3.24.2 zod-to-json-schema: 3.24.5(zod@3.24.2) - '@ai-sdk/xai@1.2.10(zod@3.24.2)': + '@ai-sdk/xai@1.2.15(zod@3.24.2)': dependencies: - '@ai-sdk/openai-compatible': 0.2.8(zod@3.24.2) - '@ai-sdk/provider': 1.1.2 - '@ai-sdk/provider-utils': 2.2.6(zod@3.24.2) + '@ai-sdk/openai-compatible': 0.2.13(zod@3.24.2) + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.7(zod@3.24.2) zod: 3.24.2 '@alloc/quick-lru@5.2.0': {} @@ -4849,6 +4894,26 @@ snapshots: '@radix-ui/rect@1.1.0': {} + '@redis/bloom@5.0.0(@redis/client@5.0.0)': + dependencies: + '@redis/client': 5.0.0 + + '@redis/client@5.0.0': + dependencies: + cluster-key-slot: 1.1.2 + + '@redis/json@5.0.0(@redis/client@5.0.0)': + dependencies: + '@redis/client': 5.0.0 + + '@redis/search@5.0.0(@redis/client@5.0.0)': + dependencies: + '@redis/client': 5.0.0 + + '@redis/time-series@5.0.0(@redis/client@5.0.0)': + dependencies: + '@redis/client': 5.0.0 + '@rtsao/scc@1.1.0': {} '@rushstack/eslint-patch@1.11.0': {} @@ -5009,12 +5074,12 @@ snapshots: acorn@8.14.1: {} - ai@4.3.4(react@19.0.0-rc-45804af1-20241021)(zod@3.24.2): + ai@4.3.13(react@19.0.0-rc-45804af1-20241021)(zod@3.24.2): dependencies: - '@ai-sdk/provider': 1.1.2 - '@ai-sdk/provider-utils': 2.2.6(zod@3.24.2) - '@ai-sdk/react': 1.2.8(react@19.0.0-rc-45804af1-20241021)(zod@3.24.2) - '@ai-sdk/ui-utils': 1.2.7(zod@3.24.2) + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.7(zod@3.24.2) + '@ai-sdk/react': 1.2.11(react@19.0.0-rc-45804af1-20241021)(zod@3.24.2) + '@ai-sdk/ui-utils': 1.2.10(zod@3.24.2) '@opentelemetry/api': 1.9.0 jsondiffpatch: 0.6.0 zod: 3.24.2 @@ -5233,6 +5298,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} + codemirror@6.0.1: dependencies: '@codemirror/autocomplete': 6.18.6 @@ -7125,6 +7192,14 @@ snapshots: dependencies: picomatch: 2.3.1 + redis@5.0.0: + dependencies: + '@redis/bloom': 5.0.0(@redis/client@5.0.0) + '@redis/client': 5.0.0 + '@redis/json': 5.0.0(@redis/client@5.0.0) + '@redis/search': 5.0.0(@redis/client@5.0.0) + '@redis/time-series': 5.0.0(@redis/client@5.0.0) + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -7195,6 +7270,8 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + resumable-stream@2.0.0: {} + retry@0.13.1: {} reusify@1.1.0: {} diff --git a/tests/routes/chat.test.ts b/tests/routes/chat.test.ts index 5379fb5..1a7560f 100644 --- a/tests/routes/chat.test.ts +++ b/tests/routes/chat.test.ts @@ -80,4 +80,158 @@ test.describe const deletedChat = await response.json(); expect(deletedChat).toMatchObject({ id: chatId }); }); + + test('Ada cannot resume stream of chat that does not exist', async ({ + adaContext, + }) => { + const response = await adaContext.request.get( + `/api/chat?chatId=${generateUUID()}`, + ); + expect(response.status()).toBe(404); + }); + + test('Ada can resume chat generation', async ({ adaContext }) => { + const chatId = generateUUID(); + + const firstRequest = adaContext.request.post('/api/chat', { + data: { + id: chatId, + message: { + id: generateUUID(), + role: 'user', + content: 'Help me write an essay about Silcon Valley', + parts: [ + { + type: 'text', + text: 'Help me write an essay about Silicon Valley', + }, + ], + createdAt: new Date().toISOString(), + }, + selectedChatModel: 'chat-model', + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const secondRequest = adaContext.request.get( + `/api/chat?chatId=${chatId}`, + ); + + const [firstResponse, secondResponse] = await Promise.all([ + firstRequest, + secondRequest, + ]); + + const [firstStatusCode, secondStatusCode] = await Promise.all([ + firstResponse.status(), + secondResponse.status(), + ]); + + expect(firstStatusCode).toBe(200); + expect(secondStatusCode).toBe(200); + + const [firstResponseBody, secondResponseBody] = await Promise.all([ + await firstResponse.body(), + await secondResponse.body(), + ]); + + expect(firstResponseBody.toString()).toEqual( + secondResponseBody.toString(), + ); + }); + + test('Ada cannot resume chat generation that has ended', async ({ + adaContext, + }) => { + const chatId = generateUUID(); + + const firstRequest = await adaContext.request.post('/api/chat', { + data: { + id: chatId, + message: { + id: generateUUID(), + role: 'user', + content: 'Help me write an essay about Silcon Valley', + parts: [ + { + type: 'text', + text: 'Help me write an essay about Silicon Valley', + }, + ], + createdAt: new Date().toISOString(), + }, + selectedChatModel: 'chat-model', + }, + }); + + const secondRequest = adaContext.request.get( + `/api/chat?chatId=${chatId}`, + ); + + const [firstResponse, secondResponse] = await Promise.all([ + firstRequest, + secondRequest, + ]); + + const [firstStatusCode, secondStatusCode] = await Promise.all([ + firstResponse.status(), + secondResponse.status(), + ]); + + expect(firstStatusCode).toBe(200); + expect(secondStatusCode).toBe(200); + + const [, secondResponseContent] = await Promise.all([ + firstResponse.text(), + secondResponse.text(), + ]); + + expect(secondResponseContent).toEqual(''); + }); + + test('Babbage cannot resume chat generation that belongs to Ada', async ({ + adaContext, + babbageContext, + }) => { + const chatId = generateUUID(); + + const firstRequest = adaContext.request.post('/api/chat', { + data: { + id: chatId, + message: { + id: generateUUID(), + role: 'user', + content: 'Help me write an essay about Silcon Valley', + parts: [ + { + type: 'text', + text: 'Help me write an essay about Silicon Valley', + }, + ], + createdAt: new Date().toISOString(), + }, + selectedChatModel: 'chat-model', + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const secondRequest = babbageContext.request.get( + `/api/chat?chatId=${chatId}`, + ); + + const [firstResponse, secondResponse] = await Promise.all([ + firstRequest, + secondRequest, + ]); + + const [firstStatusCode, secondStatusCode] = await Promise.all([ + firstResponse.status(), + secondResponse.status(), + ]); + + expect(firstStatusCode).toBe(200); + expect(secondStatusCode).toBe(403); + }); });