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);
+ });
});