this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Generated chat titles, new chat button and general improvements

+169 -54
+18
apps/api/src/lib/ai/chat.ts
··· 1 + import { streamText } from "ai"; 2 + import { chatModel } from "./model"; 3 + 4 + interface StreamChatOptions { 5 + system: string; 6 + messages: Array<{ 7 + role: "assistant" | "user"; 8 + content: string; 9 + }>; 10 + } 11 + 12 + export function streamChat({ system, messages }: StreamChatOptions) { 13 + return streamText({ 14 + model: chatModel, 15 + system, 16 + messages, 17 + }); 18 + }
+3
apps/api/src/lib/ai/model.ts
··· 1 + import { openai } from "@ai-sdk/openai"; 2 + 3 + export const chatModel = openai("gpt-5.4-mini");
+21
apps/api/src/lib/ai/title.ts
··· 1 + import { generateObject } from "ai"; 2 + import { z } from "zod"; 3 + import { chatModel } from "./model"; 4 + 5 + const chatTitleSchema = z.object({ 6 + title: z 7 + .string() 8 + .min(1) 9 + .describe("A concise chat title, 2 to 4 words, without wrapping quotes."), 10 + }); 11 + 12 + export function generateChatTitle(message: string) { 13 + return generateObject({ 14 + model: chatModel, 15 + schema: chatTitleSchema, 16 + schemaName: "ChatTitle", 17 + system: 18 + "You write short chat titles. Return a concise title based on the user's first message.", 19 + prompt: `Write a concise title, 2 to 4 words, for this first message:\n\n${message}`, 20 + }); 21 + }
+49 -9
apps/api/src/routes/chats.ts
··· 1 - import { openai } from "@ai-sdk/openai"; 2 1 import { zValidator } from "@hono/zod-validator"; 3 - import { streamText } from "ai"; 4 2 import { asc, desc, eq } from "drizzle-orm"; 5 3 import { Hono } from "hono"; 4 + import { streamChat } from "../lib/ai/chat"; 5 + import { generateChatTitle } from "../lib/ai/title"; 6 6 import { db, schema } from "../lib/db"; 7 7 import { nanoid } from "../lib/id"; 8 8 import { chatSchema, messageSchema } from "../lib/schema"; ··· 64 64 orderBy: asc(schema.messages.createdAt), 65 65 }); 66 66 67 - const res = streamText({ 68 - model: openai("gpt-5.4-mini"), 67 + const titleResultPromise = 68 + messages.length === 1 ? generateChatTitle(body.text) : undefined; 69 + 70 + const res = streamChat({ 69 71 system: chat.character.prompt, 70 72 messages: messages.map((message) => ({ 71 73 role: message.role, 72 74 content: message.text, 73 75 })), 74 - async onFinish({ text }) { 75 - db.insert(schema.messages) 76 - .values({ id: nanoid(), chatId: id, role: "assistant", text }) 77 - .run(); 76 + }); 77 + 78 + const stream = new ReadableStream({ 79 + async start(controller) { 80 + const encoder = new TextEncoder(); 81 + let assistantText = ""; 82 + 83 + try { 84 + for await (const text of res.textStream) { 85 + assistantText += text; 86 + controller.enqueue(encoder.encode(text)); 87 + } 88 + 89 + if (titleResultPromise) { 90 + const titleResult = await titleResultPromise; 91 + const { title } = titleResult.object; 92 + 93 + if (title) { 94 + db.update(schema.chats) 95 + .set({ title }) 96 + .where(eq(schema.chats.id, id)) 97 + .run(); 98 + } 99 + } 100 + 101 + db.insert(schema.messages) 102 + .values({ 103 + id: nanoid(), 104 + chatId: id, 105 + role: "assistant", 106 + text: assistantText, 107 + }) 108 + .run(); 109 + 110 + controller.close(); 111 + } catch (error) { 112 + controller.error(error); 113 + } 78 114 }, 79 115 }); 80 116 81 - return res.toTextStreamResponse(); 117 + return new Response(stream, { 118 + headers: { 119 + "Content-Type": "text/plain; charset=utf-8", 120 + }, 121 + }); 82 122 }) 83 123 .patch("/:id", zValidator("json", chatSchema.partial()), async (c) => { 84 124 const { id } = c.req.param();
+3 -1
apps/web/src/components/character-select.tsx
··· 21 21 <SelectTrigger className="min-w-40 px-4 data-[size=default]:h-10"> 22 22 <SelectValue placeholder="Characters"> 23 23 {(value) => 24 - typeof value === "string" ? (characterNames.get(value) ?? value) : null 24 + typeof value === "string" 25 + ? (characterNames.get(value) ?? value) 26 + : null 25 27 } 26 28 </SelectValue> 27 29 </SelectTrigger>
+7 -1
apps/web/src/components/chat-form.tsx
··· 10 10 interface ChatFormProps { 11 11 chatId?: string; 12 12 characterId?: string; 13 + className?: string; 13 14 } 14 15 15 - export function ChatForm({ chatId, characterId: chatCharacterId }: ChatFormProps) { 16 + export function ChatForm({ 17 + chatId, 18 + characterId: chatCharacterId, 19 + className, 20 + }: ChatFormProps) { 16 21 const { data: characters = [] } = useCharacters(); 17 22 const sendMessage = useSendMessage(); 18 23 const [characterId, setCharacterId] = useState<string | null>(null); ··· 39 44 }, 40 45 ); 41 46 }} 47 + className={className} 42 48 > 43 49 <Stack gap="sm"> 44 50 <Textarea
+12 -9
apps/web/src/components/chat-message.tsx
··· 3 3 import { Row } from "./layout"; 4 4 import { Avatar, AvatarFallback } from "./ui/avatar"; 5 5 6 - const chatMessage = cva("rounded-2xl px-4 py-2 text-sm leading-relaxed", { 7 - variants: { 8 - author: { 9 - assistant: "bg-primary text-primary-foreground", 10 - user: "bg-secondary text-secondary-foreground", 6 + const chatMessage = cva( 7 + "rounded-2xl px-4 py-2 text-sm leading-relaxed wrap-anywhere", 8 + { 9 + variants: { 10 + author: { 11 + assistant: "bg-primary text-primary-foreground", 12 + user: "bg-secondary text-secondary-foreground", 13 + }, 14 + }, 15 + defaultVariants: { 16 + author: "assistant", 11 17 }, 12 18 }, 13 - defaultVariants: { 14 - author: "assistant", 15 - }, 16 - }); 19 + ); 17 20 18 21 type ChatMessageProps = React.HTMLAttributes<HTMLDivElement> & 19 22 VariantProps<typeof chatMessage>;
+20 -10
apps/web/src/components/chat-messages.tsx
··· 1 + import { useEffect, useRef } from "react"; 1 2 import type { ChatMessage as ChatMessageType } from "#/lib/types"; 2 3 import { ChatMessage } from "./chat-message"; 3 4 import { Stack } from "./layout"; ··· 8 9 } 9 10 10 11 export function ChatMessages({ messages }: ChatMessageProps) { 12 + const endRef = useRef<HTMLDivElement>(null); 13 + 14 + useEffect(() => { 15 + if (messages.length === 0) return; 16 + endRef.current?.scrollIntoView({ block: "end", behavior: "instant" }); 17 + }, [messages]); 18 + 11 19 return ( 12 - <Stack> 13 - {messages.map((message) => ( 14 - <ChatMessage key={message.id} author={message.role}> 15 - {message.role === "assistant" && message.text.length === 0 ? ( 16 - <TypingIndicator /> 17 - ) : ( 18 - <p>{message.text}</p> 19 - )} 20 - </ChatMessage> 21 - ))} 20 + <Stack gap="sm" className="no-scrollbar flex-1 overflow-y-auto"> 21 + {messages.map((message) => { 22 + const isTyping = 23 + message.role === "assistant" && message.text.length === 0; 24 + 25 + return ( 26 + <ChatMessage key={message.id} author={message.role}> 27 + {isTyping ? <TypingIndicator /> : <p>{message.text}</p>} 28 + </ChatMessage> 29 + ); 30 + })} 31 + <div ref={endRef} /> 22 32 </Stack> 23 33 ); 24 34 }
+10 -9
apps/web/src/components/chat-sidebar.tsx
··· 1 1 import { Link, useLocation } from "@tanstack/react-router"; 2 2 import { useChats } from "#/hooks/use-chats"; 3 3 import type { ChatSummary } from "#/lib/types"; 4 - import { Row } from "./layout"; 4 + import { Heading } from "./heading"; 5 + import { Stack } from "./layout"; 5 6 import { Logo } from "./logo"; 6 - import { Text } from "./text"; 7 + import { Button } from "./ui/button"; 7 8 import { 8 9 Sidebar, 9 10 SidebarContent, 10 - SidebarFooter, 11 11 SidebarGroup, 12 12 SidebarGroupLabel, 13 13 SidebarHeader, ··· 22 22 return ( 23 23 <Sidebar variant="inset"> 24 24 <SidebarHeader> 25 - <Row className="px-3 pt-3"> 26 - <Text> 25 + <Stack className="px-3 pt-3"> 26 + <Heading level="h3"> 27 27 <Link to="/"> 28 28 <Logo /> 29 29 </Link> 30 - </Text> 31 - </Row> 30 + </Heading> 31 + 32 + <Button render={<Link to="/chats" />}>New Chat</Button> 33 + </Stack> 32 34 </SidebarHeader> 33 35 <SidebarContent> 34 36 <SidebarGroup> ··· 40 42 </SidebarMenu> 41 43 </SidebarGroup> 42 44 </SidebarContent> 43 - <SidebarFooter /> 44 45 </Sidebar> 45 46 ); 46 47 } ··· 60 61 render={<Link to="/chats/$chatId" params={{ chatId: chat.id }} />} 61 62 isActive={pathname === `/chats/${chat.id}`} 62 63 > 63 - {chat.title} 64 + <span className="truncate">{chat.title}</span> 64 65 </SidebarMenuButton> 65 66 </SidebarMenuItem> 66 67 );
+1 -1
apps/web/src/components/chat.tsx
··· 11 11 const { chat, messages } = useChat({ id: chatId }); 12 12 13 13 return ( 14 - <Stack justify="between" className="flex-1 py-4 md:py-8"> 14 + <Stack className="flex-1 py-4"> 15 15 <ChatMessages messages={messages} /> 16 16 <ChatForm chatId={chatId} characterId={chat?.characterId} /> 17 17 </Stack>
+19 -8
apps/web/src/hooks/use-send-message.ts
··· 5 5 } from "@tanstack/react-query"; 6 6 import { useNavigate } from "@tanstack/react-router"; 7 7 import { client } from "#/lib/api"; 8 - import type { Chat, ChatMessage } from "#/lib/types"; 8 + import type { Chat, ChatMessage, ChatSummary } from "#/lib/types"; 9 9 10 10 const chatQueryKey = (chatId: string) => ["chat", chatId] as const; 11 + const chatsQueryKey = ["chats"] as const; 11 12 12 13 interface SendMessageInput { 13 14 chatId?: string; ··· 53 54 ); 54 55 } 55 56 57 + function addChatToList(queryClient: QueryClient, chat: ChatSummary) { 58 + queryClient.setQueryData<ChatSummary[] | undefined>( 59 + chatsQueryKey, 60 + (chats = []) => { 61 + if (chats.some((currentChat) => currentChat.id === chat.id)) { 62 + return chats; 63 + } 64 + 65 + return [chat, ...chats]; 66 + }, 67 + ); 68 + } 69 + 56 70 async function sendMessageToChat(chatId: string, text: string) { 57 71 const response = await client.api.chats[":id"].messages.$post({ 58 72 param: { id: chatId }, ··· 100 114 async function getChatId({ 101 115 chatId, 102 116 characterId, 103 - text, 104 - }: SendMessageInput) { 117 + }: Omit<SendMessageInput, "text">) { 105 118 if (chatId) return chatId; 106 119 107 120 if (!characterId) { ··· 110 123 111 124 const response = await client.api.chats.$post({ 112 125 json: { 113 - title: text, 126 + title: "New chat", 114 127 characterId, 115 128 }, 116 129 }); ··· 125 138 ...chat, 126 139 messages: [], 127 140 }); 128 - await queryClient.invalidateQueries({ queryKey: ["chats"] }); 141 + addChatToList(queryClient, chat); 129 142 await navigate({ to: "/chats/$chatId", params: { chatId: chat.id } }); 130 143 131 144 return chat.id; ··· 142 155 const id = await getChatId({ 143 156 chatId, 144 157 characterId, 145 - text: trimmedText, 146 158 }); 147 159 148 160 await queryClient.cancelQueries({ queryKey: chatQueryKey(id) }); ··· 158 170 159 171 try { 160 172 const response = await sendMessageToChat(id, trimmedText); 161 - 162 173 await readTextStream(response, (chunk) => { 163 174 patchMessage(queryClient, id, assistantMessage.id, (message) => ({ 164 175 ...message, ··· 173 184 throw error; 174 185 } finally { 175 186 await queryClient.invalidateQueries({ queryKey: chatQueryKey(id) }); 176 - await queryClient.invalidateQueries({ queryKey: ["chats"] }); 187 + await queryClient.invalidateQueries({ queryKey: chatsQueryKey }); 177 188 } 178 189 179 190 return { id };
+3 -3
apps/web/src/routes/chats.index.tsx
··· 10 10 11 11 function RouteComponent() { 12 12 return ( 13 - <Stack justify="between" className="flex-1 py-4 md:py-8"> 14 - <Stack justify="center" className="flex-1"> 13 + <Stack className="h-full min-h-0 flex-1 flex-col justify-between overflow-hidden py-4 md:py-8"> 14 + <Stack justify="center" className="min-h-0 flex-1 overflow-y-auto pr-1"> 15 15 <Heading>What's on your mind?</Heading> 16 16 17 17 <figure className="relative"> ··· 32 32 </figcaption> 33 33 </figure> 34 34 </Stack> 35 - <ChatForm /> 35 + <ChatForm className="shrink-0" /> 36 36 </Stack> 37 37 ); 38 38 }
+3 -3
apps/web/src/routes/chats.tsx
··· 9 9 10 10 function RouteComponent() { 11 11 return ( 12 - <SidebarProvider> 12 + <SidebarProvider className="h-dvh"> 13 13 <ChatSidebar /> 14 - <SidebarInset> 15 - <Container size="sm" className="flex h-full"> 14 + <SidebarInset className="min-h-0 overflow-hidden"> 15 + <Container size="sm" className="flex h-full min-h-0 overflow-hidden"> 16 16 <Outlet /> 17 17 </Container> 18 18 </SidebarInset>