From 76f3c420f22ad48736e9f55b0cf797f7fea6afea Mon Sep 17 00:00:00 2001 From: zolinthecow Date: Sat, 24 Aug 2024 11:46:12 -0700 Subject: [PATCH 1/7] extract name and link supabase auth properly --- apps/ui/src/routes/index.lazy.tsx | 112 ++++++++++++++++++++---------- 1 file changed, 75 insertions(+), 37 deletions(-) diff --git a/apps/ui/src/routes/index.lazy.tsx b/apps/ui/src/routes/index.lazy.tsx index 3c5731a..7cc0d89 100644 --- a/apps/ui/src/routes/index.lazy.tsx +++ b/apps/ui/src/routes/index.lazy.tsx @@ -1,8 +1,10 @@ import InputBox from '@/components/InputBox'; import { Button } from '@/components/ui/button'; import { queryClient } from '@/lib/reactQuery'; +import { supabase } from '@/lib/supabase'; import { type TRPCOutputs, trpc } from '@/lib/trpc'; import type { AsyncGeneratorYieldType } from '@/lib/utils'; +import type { Session } from '@supabase/supabase-js'; import { createLazyFileRoute, useRouteContext, @@ -10,15 +12,32 @@ import { } from '@tanstack/react-router'; import { getQueryKey } from '@trpc/react-query'; import { DateTime } from 'luxon'; +import { useTransition } from 'react'; export const Route = createLazyFileRoute('/')({ component: Index, }); +function maybeGetName(session: Session | null): null | string { + if (!session) return null; + const name = + session.user.identities?.[0].identity_data?.full_name || + session.user.identities?.[0].identity_data?.full_name; + if (!name) return null; + const nameParts = name.split(' '); + if (nameParts.length === 1) { + return nameParts[0]; + } + return nameParts.slice(0, -1).join(' '); +} + function Index() { const router = useRouter(); const context = useRouteContext({ from: '/' }); + const name = maybeGetName(context.session); + const [pendingSubmitChatMessage, startSubmitChatMessageTransition] = + useTransition(); const createChatMutation = trpc.chat.create.useMutation(); const generateResponseMutation = trpc.chatMessages.generateResponse.useMutation(); @@ -34,50 +53,67 @@ function Index() { } }; - const handleSubmit = async (text: string): Promise => { - // Create the chat and send the first message - const createChatResp = await createChatMutation.mutateAsync({ - initialMessage: text, - }); - await queryClient.invalidateQueries({ - queryKey: getQueryKey(trpc.chat.infiniteList, undefined, 'any'), - }); - const generateResponseResp = await generateResponseMutation.mutateAsync( - { - messageID: createChatResp.messages[0].id, - }, - ); + const handleSubmit = (text: string): void => + startSubmitChatMessageTransition(async () => { + // Create the chat and send the first message + const createChatResp = await createChatMutation.mutateAsync({ + initialMessage: text, + }); + await queryClient.invalidateQueries({ + queryKey: getQueryKey(trpc.chat.infiniteList, undefined, 'any'), + }); + const generateResponseResp = + await generateResponseMutation.mutateAsync({ + messageID: createChatResp.messages[0].id, + }); - // Convert the async generator into a readable stream - const stream = new ReadableStream< - AsyncGeneratorYieldType< - TRPCOutputs['chatMessages']['generateResponse'] - > - >({ - async start(controller) { - for await (const chunk of generateResponseResp) { - controller.enqueue(chunk); - } - controller.close(); - }, + // Convert the async generator into a readable stream + const stream = new ReadableStream< + AsyncGeneratorYieldType< + TRPCOutputs['chatMessages']['generateResponse'] + > + >({ + async start(controller) { + for await (const chunk of generateResponseResp) { + controller.enqueue(chunk); + } + controller.close(); + }, + }); + + // Now the stream gets passed into the router context so you can just keep + // reading from it without worrying about resetting it + + context.initialChatStream = stream; + router.navigate({ + from: '/', + to: '/c/$chatID', + params: { + chatID: createChatResp.id, + }, + }); }); - // Now the stream gets passed into the router context so you can just keep - // reading from it without worrying about resetting it + const [pendingLogin, startLoginTransition] = useTransition(); - context.initialChatStream = stream; - router.navigate({ - from: '/', - to: '/c/$chatID', - params: { - chatID: createChatResp.id, - }, + const handleLogin = (): void => + startLoginTransition(async () => { + const { data, error } = await supabase.auth.linkIdentity({ + provider: 'google', + }); + if (error) { + console.error(error); + return; + } + console.log('SUCCESS', data); }); - }; return (
-

Good {getGreeting()}, Colin :)

+

+ Good {getGreeting()} + {name != null ? `, ${name}` : ''} :) +

{(!context.session || context.session.user.is_anonymous) && ( - + )}
); From 64e0b74efb09eaebca13c1b57825c487a3bb69c8 Mon Sep 17 00:00:00 2001 From: zolinthecow Date: Sat, 24 Aug 2024 23:04:54 -0700 Subject: [PATCH 2/7] create chat returns stream to avoid second round trip --- apps/server/src/trpc/routers/chat/create.ts | 74 +++++++++++++++++-- .../src/trpc/routers/chatMessages/send.ts | 2 +- apps/ui/src/routes/__root.tsx | 2 +- apps/ui/src/routes/c/$chatID.tsx | 9 ++- apps/ui/src/routes/index.lazy.tsx | 26 ++++--- 5 files changed, 89 insertions(+), 24 deletions(-) diff --git a/apps/server/src/trpc/routers/chat/create.ts b/apps/server/src/trpc/routers/chat/create.ts index 4144738..b1bf316 100644 --- a/apps/server/src/trpc/routers/chat/create.ts +++ b/apps/server/src/trpc/routers/chat/create.ts @@ -1,19 +1,33 @@ -// Creates a new empty chat in the DB. Must call this before sending any messages, even from the home -// page. +// Creates a DB chat and streams down the response. Chats can only be created from the home page. import { type DBChat, type DBChatMessage, DBChatSchema } from '@repo/db'; import { sql } from 'slonik'; import { ulid } from 'ulid'; import { z } from 'zod'; import { publicProcedure } from '../../trpc'; -import { upsertDBChatMessage } from '../chatMessages/send'; +import { + type SendMessageOutput, + updateDBChatMessage, + upsertDBChatMessage, +} from '../chatMessages/send'; export const CreateChatSchema = z.object({ initialMessage: z.string().optional(), }); + +type CreateChatOutput = + | { + type: 'chat'; + chat: DBChat; + } + | SendMessageOutput; + export const create = publicProcedure .input(CreateChatSchema) - .mutation(async ({ input, ctx }) => { + .mutation(async function* ({ + input, + ctx, + }): AsyncGenerator { const chatID = ulid(); const newChat = (await ctx.dbPool.one(sql.type(DBChatSchema)` @@ -40,14 +54,58 @@ export const create = publicProcedure chatID, messageContent: input.initialMessage, messageType: 'user', - responseStatus: 'not_started', + responseStatus: 'streaming', }, ctx.dbPool, ); messages.push(initialMessage); } - return { - ...newChat, - messages, + + yield { + type: 'chat', + chat: newChat, + }; + + if (!input.initialMessage) return; + + const chatIterator = ctx.chatService.generateResponse({ + userID: ctx.user.id, + chatID, + message: input.initialMessage, + previousMessages: [], + }); + + let fullMessage = ''; + let messageID = ''; + for await (const chunk of chatIterator) { + yield { + type: 'messageChunk', + messageChunk: chunk, + }; + messageID = chunk.id; + fullMessage += chunk.messageContent; + } + + const completedAssistantMessage = await upsertDBChatMessage( + { + id: messageID, + userID: ctx.user.id, + chatID: chatID, + messageType: 'assistant', + messageContent: fullMessage, + }, + ctx.dbPool, + ); + yield { + type: 'completeMessage', + message: completedAssistantMessage, }; + + await updateDBChatMessage( + { + messageID: messageID, + responseStatus: 'done', + }, + ctx.dbPool, + ); }); diff --git a/apps/server/src/trpc/routers/chatMessages/send.ts b/apps/server/src/trpc/routers/chatMessages/send.ts index c613738..f4c3591 100644 --- a/apps/server/src/trpc/routers/chatMessages/send.ts +++ b/apps/server/src/trpc/routers/chatMessages/send.ts @@ -19,7 +19,7 @@ export const SendMessageSchema = z.object({ chatID: z.string(), generateResponse: z.boolean().default(true), }); -type SendMessageOutput = +export type SendMessageOutput = | { type: 'userMessage'; message: DBChatMessage; diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 88f2149..42190ec 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -29,7 +29,7 @@ import { useState } from 'react'; type RouterContext = { initialChatStream: ReadableStream< - AsyncGeneratorYieldType + AsyncGeneratorYieldType > | null; session: Session | null; }; diff --git a/apps/ui/src/routes/c/$chatID.tsx b/apps/ui/src/routes/c/$chatID.tsx index b68a2cb..65120d0 100644 --- a/apps/ui/src/routes/c/$chatID.tsx +++ b/apps/ui/src/routes/c/$chatID.tsx @@ -108,9 +108,14 @@ function Chat() { }; const processMessageChunk = async ( - chunk: AsyncGeneratorYieldType, + chunk: + | AsyncGeneratorYieldType + | AsyncGeneratorYieldType, ): Promise => { - if (chunk.type === 'userMessage') { + if (chunk.type === 'chat') { + // WTF + return; + } else if (chunk.type === 'userMessage') { queryClient.setQueryData( infiniteMessagesQueryKey, (prevData: InfiniteQueryData) => { diff --git a/apps/ui/src/routes/index.lazy.tsx b/apps/ui/src/routes/index.lazy.tsx index 7cc0d89..b79d892 100644 --- a/apps/ui/src/routes/index.lazy.tsx +++ b/apps/ui/src/routes/index.lazy.tsx @@ -39,8 +39,6 @@ function Index() { const [pendingSubmitChatMessage, startSubmitChatMessageTransition] = useTransition(); const createChatMutation = trpc.chat.create.useMutation(); - const generateResponseMutation = - trpc.chatMessages.generateResponse.useMutation(); const getGreeting = () => { const hour = DateTime.local().hour; @@ -56,31 +54,35 @@ function Index() { const handleSubmit = (text: string): void => startSubmitChatMessageTransition(async () => { // Create the chat and send the first message - const createChatResp = await createChatMutation.mutateAsync({ + const createChatGenerator = await createChatMutation.mutateAsync({ initialMessage: text, }); await queryClient.invalidateQueries({ queryKey: getQueryKey(trpc.chat.infiniteList, undefined, 'any'), }); - const generateResponseResp = - await generateResponseMutation.mutateAsync({ - messageID: createChatResp.messages[0].id, - }); // Convert the async generator into a readable stream const stream = new ReadableStream< - AsyncGeneratorYieldType< - TRPCOutputs['chatMessages']['generateResponse'] - > + AsyncGeneratorYieldType >({ async start(controller) { - for await (const chunk of generateResponseResp) { + for await (const chunk of createChatGenerator) { controller.enqueue(chunk); } controller.close(); }, }); + // Get the first value from the stream which should be a chat + const reader = stream.getReader(); + const { done, value: chunk } = await reader.read(); + reader.releaseLock(); + if (chunk?.type !== 'chat') { + // TODO: handle error + console.error('FIRST VALUE NOT CHAT??'); + return; + } + // Now the stream gets passed into the router context so you can just keep // reading from it without worrying about resetting it @@ -89,7 +91,7 @@ function Index() { from: '/', to: '/c/$chatID', params: { - chatID: createChatResp.id, + chatID: chunk.chat.id, }, }); }); From 993253d6d6a8c82aee9d66704f85ce5964967a1e Mon Sep 17 00:00:00 2001 From: zolinthecow Date: Sat, 24 Aug 2024 23:06:32 -0700 Subject: [PATCH 3/7] dont need generateResponse now --- .../routers/chatMessages/generateResponse.ts | 127 ------------------ .../src/trpc/routers/chatMessages/index.ts | 2 - .../src/trpc/routers/chatMessages/send.ts | 6 - 3 files changed, 135 deletions(-) delete mode 100644 apps/server/src/trpc/routers/chatMessages/generateResponse.ts diff --git a/apps/server/src/trpc/routers/chatMessages/generateResponse.ts b/apps/server/src/trpc/routers/chatMessages/generateResponse.ts deleted file mode 100644 index 7667520..0000000 --- a/apps/server/src/trpc/routers/chatMessages/generateResponse.ts +++ /dev/null @@ -1,127 +0,0 @@ -// This procedure generates a response to a given user message. It will overwrite any -// currently generating response. - -import { type DBChatMessage, DBChatMessageSchema } from '@repo/db'; -import { type DatabasePool, sql } from 'slonik'; -import { z } from 'zod'; -import { publicProcedure } from '../../trpc'; -import { - getPreviousChatMessages, - maybeSetChatPreview, - updateDBChatMessage, - upsertDBChatMessage, -} from './send'; - -export const GenerateResponseSchema = z.object({ - messageID: z.string().ulid(), -}); -type GenerateResponseOutput = - | { - type: 'userMessage'; - message: DBChatMessage; - } - | { - type: 'messageChunk'; - messageChunk: DBChatMessage; - } - | { - type: 'completeMessage'; - message: DBChatMessage; - }; - -export const generateResponse = publicProcedure - .input(GenerateResponseSchema) - .mutation(async function* ({ - input, - ctx, - }): AsyncGenerator { - // First update the chatMessage back to streaming. Will handle potential cancellations later - let chatMessage = await getDBChatMessage(input.messageID, ctx.dbPool); - chatMessage = await updateDBChatMessage( - { - messageID: chatMessage.id, - responseStatus: 'streaming', - }, - ctx.dbPool, - ); - yield { - type: 'userMessage', - message: chatMessage, - }; - - await maybeSetChatPreview( - { - chatID: chatMessage.chatID, - message: chatMessage.messageContent, - }, - ctx.dbPool, - ); - - const previousMessages = await getPreviousChatMessages( - { chatID: chatMessage.chatID }, - ctx.dbPool, - ); - - let messageID = chatMessage.responseMessageID; - const chatIterator = ctx.chatService.generateResponse({ - userID: ctx.user.id, - chatID: chatMessage.chatID, - message: chatMessage.messageContent, - messageID, - previousMessages: previousMessages.slice(1), - customSystemPrompt: undefined, - }); - - let fullMessage = ''; - for await (const chunk of chatIterator) { - // While the message is in the process of generating, we do not do database updates to it. - // No point slowing it down. If the user cancels the request in the middle it'll still be - // there locally so they can edit it. If they refresh the page its fine for a half-complete - // generation to just disappear as if it never happened. - yield { - type: 'messageChunk', - messageChunk: chunk, - }; - messageID = chunk.id; - fullMessage += chunk.messageContent; - } - - const completedAssistantMessage = await upsertDBChatMessage( - { - id: messageID as string, // It is guaranteed to be a string TS is tripping - userID: ctx.user.id, - chatID: chatMessage.chatID, - messageType: 'assistant', - messageContent: fullMessage, - }, - ctx.dbPool, - ); - yield { - type: 'completeMessage', - message: completedAssistantMessage, - }; - - await updateDBChatMessage( - { - messageID: chatMessage.id, - responseStatus: 'done', - responseMessageID: messageID, - }, - ctx.dbPool, - ); - }); - -async function getDBChatMessage( - messageID: string, - pool: DatabasePool, -): Promise { - try { - return await pool.one(sql.type(DBChatMessageSchema)` - SELECT * FROM "ChatMessage" - WHERE id = ${messageID} - `); - } catch (e) { - console.error(e); - throw e; - } -} diff --git a/apps/server/src/trpc/routers/chatMessages/index.ts b/apps/server/src/trpc/routers/chatMessages/index.ts index fe22c97..3391dde 100644 --- a/apps/server/src/trpc/routers/chatMessages/index.ts +++ b/apps/server/src/trpc/routers/chatMessages/index.ts @@ -1,10 +1,8 @@ import { router } from '../../trpc'; -import { generateResponse } from './generateResponse'; import { infiniteList } from './infiniteList'; import { send } from './send'; export const chatMessagesRouter = router({ send, infiniteList, - generateResponse, }); diff --git a/apps/server/src/trpc/routers/chatMessages/send.ts b/apps/server/src/trpc/routers/chatMessages/send.ts index f4c3591..51f4246 100644 --- a/apps/server/src/trpc/routers/chatMessages/send.ts +++ b/apps/server/src/trpc/routers/chatMessages/send.ts @@ -17,7 +17,6 @@ export const SendMessageSchema = z.object({ message: z.string(), customSystemPrompt: z.string().optional(), chatID: z.string(), - generateResponse: z.boolean().default(true), }); export type SendMessageOutput = | { @@ -61,11 +60,6 @@ export const send = publicProcedure ctx.dbPool, ); - if (!input.generateResponse) { - // Done - return; - } - let messageID = ulid(); await updateDBChatMessage( { From a3a35ea37d45c3457109e80d3301dd19becc18d4 Mon Sep 17 00:00:00 2001 From: zolinthecow Date: Sat, 24 Aug 2024 23:17:12 -0700 Subject: [PATCH 4/7] change to use route prefix --- apps/ui/src/routeTree.gen.ts | 38 +++++++++---------- .../ui/src/routes/{__root.tsx => ~__root.tsx} | 0 .../routes/{c/$chatID.tsx => ~c/~$chatID.tsx} | 0 .../{index.lazy.tsx => ~index.lazy.tsx} | 0 apps/ui/tsr.config.json | 6 +++ 5 files changed, 25 insertions(+), 19 deletions(-) rename apps/ui/src/routes/{__root.tsx => ~__root.tsx} (100%) rename apps/ui/src/routes/{c/$chatID.tsx => ~c/~$chatID.tsx} (100%) rename apps/ui/src/routes/{index.lazy.tsx => ~index.lazy.tsx} (100%) create mode 100644 apps/ui/tsr.config.json diff --git a/apps/ui/src/routeTree.gen.ts b/apps/ui/src/routeTree.gen.ts index 658a4f4..e830aa6 100644 --- a/apps/ui/src/routeTree.gen.ts +++ b/apps/ui/src/routeTree.gen.ts @@ -8,44 +8,44 @@ // This file is auto-generated by TanStack Router -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute } from "@tanstack/react-router" // Import Routes -import { Route as rootRoute } from './routes/__root' -import { Route as CChatIDImport } from './routes/c/$chatID' +import { Route as rootRoute } from "./routes/~__root" +import { Route as CChatIDImport } from "./routes/~c/~$chatID" // Create Virtual Routes -const IndexLazyImport = createFileRoute('/')() +const IndexLazyImport = createFileRoute("/")() // Create/Update Routes const IndexLazyRoute = IndexLazyImport.update({ - path: '/', + path: "/", getParentRoute: () => rootRoute, -} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route)) +} as any).lazy(() => import("./routes/~index.lazy").then((d) => d.Route)) const CChatIDRoute = CChatIDImport.update({ - path: '/c/$chatID', + path: "/c/$chatID", getParentRoute: () => rootRoute, } as any) // Populate the FileRoutesByPath interface -declare module '@tanstack/react-router' { +declare module "@tanstack/react-router" { interface FileRoutesByPath { - '/': { - id: '/' - path: '/' - fullPath: '/' + "/": { + id: "/" + path: "/" + fullPath: "/" preLoaderRoute: typeof IndexLazyImport parentRoute: typeof rootRoute } - '/c/$chatID': { - id: '/c/$chatID' - path: '/c/$chatID' - fullPath: '/c/$chatID' + "/c/$chatID": { + id: "/c/$chatID" + path: "/c/$chatID" + fullPath: "/c/$chatID" preLoaderRoute: typeof CChatIDImport parentRoute: typeof rootRoute } @@ -62,17 +62,17 @@ export const routeTree = rootRoute.addChildren({ IndexLazyRoute, CChatIDRoute }) { "routes": { "__root__": { - "filePath": "__root.tsx", + "filePath": "~__root.tsx", "children": [ "/", "/c/$chatID" ] }, "/": { - "filePath": "index.lazy.tsx" + "filePath": "~index.lazy.tsx" }, "/c/$chatID": { - "filePath": "c/$chatID.tsx" + "filePath": "~c/~$chatID.tsx" } } } diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/~__root.tsx similarity index 100% rename from apps/ui/src/routes/__root.tsx rename to apps/ui/src/routes/~__root.tsx diff --git a/apps/ui/src/routes/c/$chatID.tsx b/apps/ui/src/routes/~c/~$chatID.tsx similarity index 100% rename from apps/ui/src/routes/c/$chatID.tsx rename to apps/ui/src/routes/~c/~$chatID.tsx diff --git a/apps/ui/src/routes/index.lazy.tsx b/apps/ui/src/routes/~index.lazy.tsx similarity index 100% rename from apps/ui/src/routes/index.lazy.tsx rename to apps/ui/src/routes/~index.lazy.tsx diff --git a/apps/ui/tsr.config.json b/apps/ui/tsr.config.json new file mode 100644 index 0000000..09664a1 --- /dev/null +++ b/apps/ui/tsr.config.json @@ -0,0 +1,6 @@ +{ + "routeFilePrefix": "~", + "routesDirectory": "./src/routes", + "generatedRouteTree": "./src/routeTree.gen.ts", + "quoteStyle": "double" +} From a80d8b268ad474367a9351fba6d8da855c242ba1 Mon Sep 17 00:00:00 2001 From: zolinthecow Date: Sat, 24 Aug 2024 23:30:37 -0700 Subject: [PATCH 5/7] clean up code a bit --- .../routes/~c/components/chatContainer.tsx | 102 ++++++++++++++++++ .../chat => routes/~c/components}/message.tsx | 2 +- apps/ui/src/routes/~c/~$chatID.tsx | 82 ++------------ 3 files changed, 109 insertions(+), 77 deletions(-) create mode 100644 apps/ui/src/routes/~c/components/chatContainer.tsx rename apps/ui/src/{components/ui/chat => routes/~c/components}/message.tsx (97%) diff --git a/apps/ui/src/routes/~c/components/chatContainer.tsx b/apps/ui/src/routes/~c/components/chatContainer.tsx new file mode 100644 index 0000000..6b9cee6 --- /dev/null +++ b/apps/ui/src/routes/~c/components/chatContainer.tsx @@ -0,0 +1,102 @@ +import type React from 'react'; +import { useLayoutEffect, useRef, useState } from 'react'; + +import { trpc } from '@/lib/trpc'; +import { DateTime } from 'luxon'; +import Message, { AssistantMessage } from './message'; + +type Props = { + chatID: string; + currentlyStreamingMessage: string | null; +}; +const ChatContainer: React.FC = ({ + chatID, + currentlyStreamingMessage, +}) => { + const messagesInfiniteQuery = + trpc.chatMessages.infiniteList.useInfiniteQuery( + { + chatID, + limit: 10, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ); + + const messages = messagesInfiniteQuery.data + ? messagesInfiniteQuery.data.pages.flatMap((page) => + page.items.map((item) => ({ + ...item, + createdAt: DateTime.fromISO(item.createdAt).toJSDate(), + updatedAt: DateTime.fromISO(item.updatedAt).toJSDate(), + })), + ) + : []; + + // We keep a ref to the chat and the bottom of the message + const chatContainerRef = useRef(null); + const chatMessagesRef = useRef(null); + const [isFull, setIsFull] = useState(false); + + // biome-ignore lint/correctness/useExhaustiveDependencies: It's fine + useLayoutEffect(() => { + // If the entire screen is filled out, then we want to start rendering messages + // from the bottom, like the chat is getting pushed upwards each new message. + // Basically, if the chat doesn't fill the height of the screen we render from + // top down. If it does we flip it 180 degrees and start rendering from bottom + // up. We do it in a layout effect to apply changes before the DOM gets painted, + // allowing for smoother animation. + + const checkContentFillsScreen = () => { + if (chatContainerRef.current && chatMessagesRef.current) { + const containerHeight = chatContainerRef.current.clientHeight; + const contentHeight = chatMessagesRef.current.scrollHeight; + return contentHeight > containerHeight; + } + return false; + }; + + if (chatMessagesRef.current) { + if (checkContentFillsScreen()) { + chatMessagesRef.current.style.flexDirection = 'column-reverse'; + setIsFull(true); + } else { + chatMessagesRef.current.style.flexDirection = 'column'; + setIsFull(false); + } + } + }, [messages, currentlyStreamingMessage]); + + return ( +
+
+
+ {/* If the chat fills the screen then we're rendering in reverse order, so + for the currently streaming message to show on the bottom it needs to + be on the top in the code */} + {currentlyStreamingMessage && isFull && ( + + )} + {/* If the chat fills the screen then we need to reverse the order of the + messages. However, the messages are *already* in reverse order since + the API returns them in latest message first. So we don't need to do + anything if the screen is full, and we need to reverse it again otherwise. */} + {(isFull ? messages : messages.slice().reverse()).map((m) => ( + + ))} + {currentlyStreamingMessage && !isFull && ( + + )} +
+
+ ); +}; + +export default ChatContainer; diff --git a/apps/ui/src/components/ui/chat/message.tsx b/apps/ui/src/routes/~c/components/message.tsx similarity index 97% rename from apps/ui/src/components/ui/chat/message.tsx rename to apps/ui/src/routes/~c/components/message.tsx index 2852cf7..0879499 100644 --- a/apps/ui/src/components/ui/chat/message.tsx +++ b/apps/ui/src/routes/~c/components/message.tsx @@ -1,8 +1,8 @@ +import UserIcon from '@/components/ui/userIcon'; import type { DBChatMessage } from '@repo/db'; import type React from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; -import UserIcon from '../userIcon'; type MessageProps = { message: DBChatMessage; diff --git a/apps/ui/src/routes/~c/~$chatID.tsx b/apps/ui/src/routes/~c/~$chatID.tsx index 65120d0..43373ef 100644 --- a/apps/ui/src/routes/~c/~$chatID.tsx +++ b/apps/ui/src/routes/~c/~$chatID.tsx @@ -1,5 +1,4 @@ import InputBox from '@/components/InputBox'; -import Message, { AssistantMessage } from '@/components/ui/chat/message'; import { queryClient } from '@/lib/reactQuery'; import { type TRPCOutputs, trpc, trpcQueryUtils } from '@/lib/trpc'; import type { AsyncGeneratorYieldType } from '@/lib/utils'; @@ -11,6 +10,8 @@ import { import { getQueryKey } from '@trpc/react-query'; import { DateTime } from 'luxon'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import ChatContainer from './components/chatContainer'; +import Message, { AssistantMessage } from './components/message'; type InfiniteQueryData = { pages: TRPCOutputs['chatMessages']['infiniteList'][]; @@ -51,27 +52,6 @@ function Chat() { from: '/c/$chatID', }); - const messagesInfiniteQuery = - trpc.chatMessages.infiniteList.useInfiniteQuery( - { - chatID, - limit: 10, - }, - { - getNextPageParam: (lastPage) => lastPage.nextCursor, - }, - ); - - const messages = messagesInfiniteQuery.data - ? messagesInfiniteQuery.data.pages.flatMap((page) => - page.items.map((item) => ({ - ...item, - createdAt: DateTime.fromISO(item.createdAt).toJSDate(), - updatedAt: DateTime.fromISO(item.updatedAt).toJSDate(), - })), - ) - : []; - const infiniteMessagesQueryKey = getQueryKey( trpc.chatMessages.infiniteList, { @@ -178,38 +158,6 @@ function Chat() { string | null >(null); - // We keep a ref to the chat and the bottom of the message - const chatContainerRef = useRef(null); - const chatMessagesRef = useRef(null); - const [isFull, setIsFull] = useState(false); - - // biome-ignore lint/correctness/useExhaustiveDependencies: It's fine - useLayoutEffect(() => { - // If the entire screen is filled out, then we want to start rendering messages - // from the bottom, like the chat is getting pushed upwards each new message. - // We do it in a layout effect to apply changes before the DOM gets painted, - // allowing for smoother animation. - - const checkContentFillsScreen = () => { - if (chatContainerRef.current && chatMessagesRef.current) { - const containerHeight = chatContainerRef.current.clientHeight; - const contentHeight = chatMessagesRef.current.scrollHeight; - return contentHeight > containerHeight; - } - return false; - }; - - if (chatMessagesRef.current) { - if (checkContentFillsScreen()) { - chatMessagesRef.current.style.flexDirection = 'column-reverse'; - setIsFull(true); - } else { - chatMessagesRef.current.style.flexDirection = 'column'; - setIsFull(false); - } - } - }, [messages, currentlyStreamingMessage]); - // If it was passed an initialMessage then it means we have a message to immediately // start rendering // biome-ignore lint/correctness/useExhaustiveDependencies: It's fine @@ -262,28 +210,10 @@ function Chat() { return (
-
-
-
- {currentlyStreamingMessage && isFull && ( - - )} - {(isFull ? messages : messages.slice().reverse()).map( - (m) => ( - - ), - )} - {currentlyStreamingMessage && !isFull && ( - - )} -
-
+
Date: Sun, 25 Aug 2024 00:35:59 -0700 Subject: [PATCH 6/7] local supabase --- apps/ui/src/routes/~index.lazy.tsx | 4 +-- infra/supabase/config.toml | 8 ++--- .../migrations/20240825065312_init.sql | 30 +++++++++++++++++++ infra/supabase/package.json | 5 +++- package.json | 2 +- pnpm-lock.yaml | 14 ++++----- 6 files changed, 48 insertions(+), 15 deletions(-) create mode 100644 infra/supabase/migrations/20240825065312_init.sql diff --git a/apps/ui/src/routes/~index.lazy.tsx b/apps/ui/src/routes/~index.lazy.tsx index b79d892..9a8e49a 100644 --- a/apps/ui/src/routes/~index.lazy.tsx +++ b/apps/ui/src/routes/~index.lazy.tsx @@ -21,8 +21,8 @@ export const Route = createLazyFileRoute('/')({ function maybeGetName(session: Session | null): null | string { if (!session) return null; const name = - session.user.identities?.[0].identity_data?.full_name || - session.user.identities?.[0].identity_data?.full_name; + session.user.identities?.[0]?.identity_data?.full_name || + session.user.identities?.[0]?.identity_data?.full_name; if (!name) return null; const nameParts = name.split(' '); if (nameParts.length === 1) { diff --git a/infra/supabase/config.toml b/infra/supabase/config.toml index 31f7ca4..203ce4f 100644 --- a/infra/supabase/config.toml +++ b/infra/supabase/config.toml @@ -85,7 +85,7 @@ enabled = true enabled = true # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used # in emails. -site_url = "http://127.0.0.1:5173" +site_url = "http://localhost:5173" # A list of *exact* URLs that auth providers are permitted to redirect to post authentication. additional_redirect_urls = ["https://127.0.0.1:5173"] # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). @@ -100,7 +100,7 @@ enable_signup = true # Allow/disallow anonymous sign-ins to your project. enable_anonymous_sign_ins = true # Allow/disallow testing manual linking of accounts -enable_manual_linking = false +enable_manual_linking = true [auth.email] # Allow/disallow new user signups via email to your project. @@ -165,12 +165,12 @@ auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" # `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, # `twitter`, `slack`, `spotify`, `workos`, `zoom`. [auth.external.google] -enabled = false +enabled = true client_id = "env(SUPABASE_GOOGLE_CLIENT_ID)" # DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: secret = "env(SUPABASE_AUTH_EXTERNAL_GOOGLE_SECRET)" # Overrides the default auth redirectUrl. -redirect_uri = "" +redirect_uri = "http://localhost:54321/auth/v1/callback" # Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, # or any other third-party OIDC providers. url = "" diff --git a/infra/supabase/migrations/20240825065312_init.sql b/infra/supabase/migrations/20240825065312_init.sql new file mode 100644 index 0000000..19d61d2 --- /dev/null +++ b/infra/supabase/migrations/20240825065312_init.sql @@ -0,0 +1,30 @@ +create table + public."Chat" ( + id text not null, + "userID" uuid not null, + "previewName" text null, + "createdAt" timestamp with time zone not null default now(), + "updatedAt" timestamp with time zone not null default now(), + constraint Chat_pkey primary key (id), + constraint Chat_userID_fkey foreign key ("userID") references auth.users (id) + ) tablespace pg_default; +CREATE UNIQUE INDEX "Chat_pkey" ON public."Chat" USING btree (id); +CREATE INDEX "Chat_userID_idx" ON public."Chat" USING btree ("userID"); + +create table + public."ChatMessage" ( + id text not null, + "userID" uuid not null, + "chatID" text not null, + "messageType" text not null, + "messageContent" text not null, + "createdAt" timestamp with time zone not null default now(), + "updatedAt" timestamp with time zone not null default now(), + "responseStatus" text null, + "responseMessageID" text null, + constraint ChatMessage_pkey primary key (id), + constraint ChatMessage_chatID_fkey foreign key ("chatID") references "Chat" (id) on update cascade on delete cascade, + constraint ChatMessage_userID_fkey foreign key ("userID") references auth.users (id) + ) tablespace pg_default; +CREATE UNIQUE INDEX "ChatMessage_pkey" ON public."ChatMessage" USING btree (id); +CREATE INDEX "ChatMessage_chatID_idx" ON public."ChatMessage" USING btree ("chatID"); diff --git a/infra/supabase/package.json b/infra/supabase/package.json index 06a1f7a..156f102 100644 --- a/infra/supabase/package.json +++ b/infra/supabase/package.json @@ -2,5 +2,8 @@ "name": "supabase", "version": "0.0.0", "private": true, - "license": "MIT" + "license": "MIT", + "scripts": { + "dev": "supabase start" + } } diff --git a/package.json b/package.json index 039afdf..2d5dc8d 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "dotenv": "^16.4.5", "husky": "^9.1.4", "lint-staged": "^15.2.8", - "supabase": "^1.188.4", + "supabase": "^1.190.0", "turbo": "^2.0.12", "typescript": "^5.5.4" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bab309e..5deb2cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: ^15.2.8 version: 15.2.8 supabase: - specifier: ^1.188.4 - version: 1.188.4 + specifier: ^1.190.0 + version: 1.190.0 turbo: specifier: ^2.0.12 version: 2.0.12 @@ -2350,8 +2350,8 @@ packages: /@types/prop-types@15.7.12: resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} - /@types/react@18.3.3: - resolution: {integrity: sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==} + /@types/react@18.3.4: + resolution: {integrity: sha512-J7W30FTdfCxDDjmfRM+/JqLHBIyl7xUIp9kwK637FGmY7+mkSFSe6L4jpZzhj5QMfLssSDP4/i75AKkrdC7/Jw==} dependencies: '@types/prop-types': 15.7.12 csstype: 3.1.3 @@ -5633,8 +5633,8 @@ packages: ts-interface-checker: 0.1.13 dev: true - /supabase@1.188.4: - resolution: {integrity: sha512-ntOhjrBlnMdacbcmGmuv7Q2JuH6oeukc5iCHFtjZxQRmE583p02HdsALEq7KdITEMjFJOvjM4xJ5/32HAYuRQQ==} + /supabase@1.190.0: + resolution: {integrity: sha512-Ez07pA+xhffXbfWAF9PfE2teW95vINFPFAbTlXUChMh4Jjm0CYO7cgg4qSxJjmnylSB3R0uo36WFEKm1wUeupA==} engines: {npm: '>=8'} hasBin: true requiresBuild: true @@ -5899,7 +5899,7 @@ packages: /types-react-dom@19.0.0-rc.1: resolution: {integrity: sha512-VSLZJl8VXCD0fAWp7DUTFUDCcZ8DVXOQmjhJMD03odgeFmu14ZQJHCXeETm3BEAhJqfgJaFkLnGkQv88sRx0fQ==} dependencies: - '@types/react': 18.3.3 + '@types/react': 18.3.4 /types-react@19.0.0-rc.1: resolution: {integrity: sha512-RshndUfqTW6K3STLPis8BtAYCGOkMbtvYsi90gmVNDZBXUyUc5juf2PE9LfS/JmOlUIRO8cWTS/1MTnmhjDqyQ==} From e830e7750ca1f06b9ac4632c3488999bb0efa555 Mon Sep 17 00:00:00 2001 From: zolinthecow Date: Sun, 25 Aug 2024 00:42:09 -0700 Subject: [PATCH 7/7] readme --- README.md | 10 +++++++++- infra/.env.sample | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 infra/.env.sample diff --git a/README.md b/README.md index 20be94d..6b17b14 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,21 @@ Features: ## Repo Structure This is a [turborepo](https://turbo.build/repo/docs) managed monorepo. Read up on it if you have never heard of it before. -There are two main components: `apps/` and `packages/`. +There are three main components: `apps/`, `packages/`, and `infra/`. `apps/` is where the applications live. Currently there is the chat UI and the backend server. `packages/` is where any shareable code should go. With pnpm workspaces, you can import local modules by adding `"[package-name]": "workspace:*"` to a `package.json`. +`infra/` is for any infrastructure, like the local supabase setup. + ## Development +First set up your `.env` files. You will need them in `apps/ui/.env`, `apps/server/.env`, and `infra/.env`. Follow the `.env.sample` files in each of them. + +For Google OAuth, follow [these steps](https://supabase.com/docs/guides/auth/social-login/auth-google) to create a Google OAuth client. Then, for local development go to the Credentials page and edit your OAuth client. Add `http://localhost:54321/auth/v1/callback` to the Authorized redirect URIs. + +Get the rest of the supabase environment variables from the output of your local supabase cli (it'll show up in `turbo dev` the first time you run it, otherwise just check `pnpm exec supabase status`). + Make sure you have [pnpm](https://pnpm.io/) installed. Then run: ``` pnpm i diff --git a/infra/.env.sample b/infra/.env.sample new file mode 100644 index 0000000..1fccd00 --- /dev/null +++ b/infra/.env.sample @@ -0,0 +1,2 @@ +SUPABASE_GOOGLE_CLIENT_ID= +SUPABASE_AUTH_EXTERNAL_GOOGLE_SECRET=