From b773fd4b2fe210ed54320e7f2b19cd2d03e501c3 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Wed, 19 Mar 2025 23:53:09 -0500 Subject: [PATCH 1/3] Rename chat --- frontend/src/components/ChatHistoryList.tsx | 39 +++++- frontend/src/components/RenameChatDialog.tsx | 127 +++++++++++++++++++ frontend/src/state/LocalStateContext.tsx | 21 +++ frontend/src/state/LocalStateContextDef.ts | 2 + 4 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/RenameChatDialog.tsx diff --git a/frontend/src/components/ChatHistoryList.tsx b/frontend/src/components/ChatHistoryList.tsx index beeb0c704..f930d1728 100644 --- a/frontend/src/components/ChatHistoryList.tsx +++ b/frontend/src/components/ChatHistoryList.tsx @@ -1,23 +1,27 @@ +import { useState } from "react"; import { useLocalState } from "@/state/useLocalState"; import { Link } from "@tanstack/react-router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; -import { MoreHorizontal, Trash } from "lucide-react"; +import { MoreHorizontal, Trash, Pencil } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { RenameChatDialog } from "@/components/RenameChatDialog"; interface ChatHistoryListProps { currentChatId?: string; } export function ChatHistoryList({ currentChatId }: ChatHistoryListProps) { - const { fetchOrCreateHistoryList, deleteChat } = useLocalState(); + const { fetchOrCreateHistoryList, deleteChat, renameChat } = useLocalState(); const navigate = useNavigate(); const queryClient = useQueryClient(); + const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); + const [selectedChat, setSelectedChat] = useState<{ id: string; title: string } | null>(null); const { isPending, @@ -40,6 +44,23 @@ export function ChatHistoryList({ currentChatId }: ChatHistoryListProps) { } }; + const handleOpenRenameDialog = (chat: { id: string; title: string }) => { + setSelectedChat(chat); + setIsRenameDialogOpen(true); + }; + + const handleRenameChat = async (chatId: string, newTitle: string) => { + try { + await renameChat(chatId, newTitle); + // Invalidate both the chat history list and the specific chat + queryClient.invalidateQueries({ queryKey: ["chatHistory"] }); + queryClient.invalidateQueries({ queryKey: ["chat", chatId] }); + } catch (error) { + console.error("Error renaming chat:", error); + throw error; + } + }; + if (error) { return
{error.message}
; } @@ -77,6 +98,10 @@ export function ChatHistoryList({ currentChatId }: ChatHistoryListProps) { + handleOpenRenameDialog(chat)}> + + Rename Chat + handleDeleteChat(chat.id)}> Delete Chat @@ -87,6 +112,16 @@ export function ChatHistoryList({ currentChatId }: ChatHistoryListProps) {
))} + + {selectedChat && ( + + )} ); } diff --git a/frontend/src/components/RenameChatDialog.tsx b/frontend/src/components/RenameChatDialog.tsx new file mode 100644 index 000000000..c84a51840 --- /dev/null +++ b/frontend/src/components/RenameChatDialog.tsx @@ -0,0 +1,127 @@ +import { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription } from "@/components/ui/alert"; + +interface RenameChatDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + chatId: string; + currentTitle: string; + onRename: (chatId: string, newTitle: string) => Promise; +} + +export function RenameChatDialog({ + open, + onOpenChange, + chatId, + currentTitle, + onRename +}: RenameChatDialogProps) { + const [newTitle, setNewTitle] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + // Set the initial value when dialog opens + useEffect(() => { + if (open) { + setNewTitle(currentTitle); + } + }, [open, currentTitle]); + + const resetForm = useCallback(() => { + setNewTitle(currentTitle); + setError(null); + setSuccess(false); + setIsLoading(false); + }, [currentTitle]); + + useEffect(() => { + if (!open) { + resetForm(); + } + }, [open, resetForm]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setSuccess(false); + + if (!newTitle.trim()) { + setError("Chat title cannot be empty."); + return; + } + + setIsLoading(true); + try { + await onRename(chatId, newTitle); + setSuccess(true); + setTimeout(() => { + onOpenChange(false); + }, 1000); // Close the dialog after 1 second + } catch (error) { + console.error("Failed to rename chat:", error); + setError("Failed to rename chat. Please try again."); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + Rename Chat + Enter a new name for this chat conversation. + +
+ {error && ( + + {error} + + )} + {success && ( + + Chat renamed successfully. + + )} +
+ + setNewTitle(e.target.value)} + required + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSubmit(e); + } + }} + /> +
+ + + + +
+
+
+ ); +} diff --git a/frontend/src/state/LocalStateContext.tsx b/frontend/src/state/LocalStateContext.tsx index de134fe4a..a38f8bc3a 100644 --- a/frontend/src/state/LocalStateContext.tsx +++ b/frontend/src/state/LocalStateContext.tsx @@ -161,6 +161,26 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode }) await put("history_list", JSON.stringify(updatedChatHistory)); } + async function renameChat(chatId: string, newTitle: string) { + try { + // Get the current chat + const chat = await getChatById(chatId); + if (!chat) throw new Error("Chat not found"); + + // Update the chat title + chat.title = newTitle; + + // Save the updated chat + await persistChat(chat); + + // The persistChat function already updates the history list + return; + } catch (error) { + console.error("Error renaming chat:", error); + throw new Error("Error renaming chat"); + } + } + function setDraftMessage(chatId: string, draft: string) { if (!chatId?.trim()) { console.error("Invalid chatId provided to setDraftMessage"); @@ -201,6 +221,7 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode }) fetchOrCreateHistoryList, clearHistory, deleteChat, + renameChat, draftMessages: localState.draftMessages, setDraftMessage, clearDraftMessage diff --git a/frontend/src/state/LocalStateContextDef.ts b/frontend/src/state/LocalStateContextDef.ts index e0ecd6ba4..6c1ade9b4 100644 --- a/frontend/src/state/LocalStateContextDef.ts +++ b/frontend/src/state/LocalStateContextDef.ts @@ -31,6 +31,7 @@ export type LocalState = { fetchOrCreateHistoryList: () => Promise; clearHistory: () => Promise; deleteChat: (chatId: string) => Promise; + renameChat: (chatId: string, newTitle: string) => Promise; /** Map of chat IDs to their draft messages */ draftMessages: Map; /** Sets a draft message for a specific chat */ @@ -51,6 +52,7 @@ export const LocalStateContext = createContext({ fetchOrCreateHistoryList: async () => [], clearHistory: async () => {}, deleteChat: async () => {}, + renameChat: async () => {}, draftMessages: new Map(), setDraftMessage: () => {}, clearDraftMessage: () => {} From 8684c65ffd32a45422d94925df429d5cb7701450 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Thu, 20 Mar 2025 00:14:18 -0500 Subject: [PATCH 2/3] Generate chat title based on summary --- frontend/src/routes/_auth.chat.$chatId.tsx | 89 ++++++++++++++++++---- frontend/src/state/LocalStateContextDef.ts | 2 +- 2 files changed, 77 insertions(+), 14 deletions(-) diff --git a/frontend/src/routes/_auth.chat.$chatId.tsx b/frontend/src/routes/_auth.chat.$chatId.tsx index 1d96e504b..9062abfee 100644 --- a/frontend/src/routes/_auth.chat.$chatId.tsx +++ b/frontend/src/routes/_auth.chat.$chatId.tsx @@ -162,14 +162,6 @@ function ChatComponent() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [queryChat, chatId, isPending]); - async function generateChatTitle(messages: ChatMessage[]): Promise { - // Find the first user message - const userMessage = messages.find((message) => message.role === "user"); - if (!userMessage) return "New Chat"; - // Use the first 50 characters of the user message - return `${userMessage.content.slice(0, 50)}`; - } - // IMPORTANT that this runs only once (because it uses the user's tokens!) const userPromptEffectRan = useRef(false); @@ -188,6 +180,57 @@ function ChatComponent() { const sendMessage = useCallback( async (input: string) => { + // Inner function to generate chat title to avoid dependency issues + async function generateChatTitle(messages: ChatMessage[]): Promise { + // Find the first user message + const userMessage = messages.find((message) => message.role === "user"); + if (!userMessage) return "New Chat"; + + try { + // Get the user's first message, truncate if too long + const userContent = userMessage.content.slice(0, 1000); // Limit to 1000 chars to save tokens + + // Use the OpenAI API to generate a concise title - use the same model as chat + const stream = openai.beta.chat.completions.stream({ + model: model, // Use the same model that's used for chat + messages: [ + { + role: "system", + content: + "You are a helpful assistant that generates concise, meaningful titles (3-5 words) for chat conversations based on the user's first message. Return only the title without quotes or explanations." + }, + { + role: "user", + content: `Generate a concise, contextual title (3-5 words) for a chat that starts with this message: "${userContent}"` + } + ], + temperature: 0.7, + max_tokens: 15, // Keep response very short + stream: true + }); + + let generatedTitle = ""; + for await (const chunk of stream) { + const content = chunk.choices[0]?.delta?.content || ""; + generatedTitle += content; + } + + // Get the final completion + await stream.finalChatCompletion(); + + // Remove quotes if present and limit length + const cleanTitle = generatedTitle + .replace(/^["']|["']$/g, "") // Remove surrounding quotes if present + .replace(/\n/g, " ") // Remove new lines + .trim(); + + return cleanTitle || userMessage.content.slice(0, 50); // Fallback to first 50 chars if generation fails + } catch (error) { + console.error("Failed to generate chat title:", error); + // Fallback to first 50 characters of user message + return userMessage.content.slice(0, 50); + } + } if (!input.trim() || !localChat) return; setError(""); @@ -262,13 +305,24 @@ function ChatComponent() { let title = localChat.title; - // Generate and update the chat title, if the current title isn't "New Chat" + // Generate and update the chat title, if the current title is "New Chat" if (title === "New Chat") { - console.log("Generating chat title"); - const newTitle = await generateChatTitle(finalMessages); + console.log("Generating AI title for chat"); + // Set a temporary title to indicate we're generating one + setLocalChat((prev) => ({ + ...prev, + title: "Generating title..." + })); + const newTitle = await generateChatTitle(finalMessages); // Get rid of quotes and any newlines in the title title = newTitle.replace(/"/g, "").replace(/\n/g, " "); + + // Update local chat with generated title + setLocalChat((prev) => ({ + ...prev, + title: title + })); } const chatCompletion = await stream.finalChatCompletion(); @@ -280,11 +334,18 @@ function ChatComponent() { // React sucks and doesn't get the latest state await persistChat({ ...localChat, title, messages: finalMessages }); + // Invalidate chat history to show the new title in the sidebar queryClient.invalidateQueries({ queryKey: ["chatHistory"], refetchType: "all" }); + // Invalidate current chat query to ensure the title update is reflected + queryClient.invalidateQueries({ + queryKey: ["chat", chatId], + refetchType: "all" + }); + // Only invalidate billing status after everything is complete queryClient.invalidateQueries({ queryKey: ["billingStatus"], @@ -303,7 +364,7 @@ function ChatComponent() { setIsLoading(false); }, - [localChat, model, openai, persistChat, queryClient, setUserPrompt] + [localChat, model, openai, persistChat, queryClient, setUserPrompt, chatId] ); return ( @@ -321,7 +382,9 @@ function ChatComponent() { >
-

+

{localChat.title}

diff --git a/frontend/src/state/LocalStateContextDef.ts b/frontend/src/state/LocalStateContextDef.ts index 6c1ade9b4..9823e64b7 100644 --- a/frontend/src/state/LocalStateContextDef.ts +++ b/frontend/src/state/LocalStateContextDef.ts @@ -25,7 +25,7 @@ export type LocalState = { billingStatus: BillingStatus | null; setBillingStatus: (status: BillingStatus) => void; setUserPrompt: (prompt: string) => void; - addChat: () => Promise; + addChat: (title?: string) => Promise; getChatById: (id: string) => Promise; persistChat: (chat: Chat) => Promise; fetchOrCreateHistoryList: () => Promise; From 484f328f7b69d021b142d8005b6fd71ad7eb217e Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Thu, 20 Mar 2025 00:44:16 -0500 Subject: [PATCH 3/3] Only do LLM title summary for paid --- frontend/src/components/RenameChatDialog.tsx | 32 +++--- frontend/src/routes/_auth.chat.$chatId.tsx | 110 ++++++++++++++----- frontend/src/state/LocalStateContext.tsx | 3 +- 3 files changed, 98 insertions(+), 47 deletions(-) diff --git a/frontend/src/components/RenameChatDialog.tsx b/frontend/src/components/RenameChatDialog.tsx index c84a51840..840c8087f 100644 --- a/frontend/src/components/RenameChatDialog.tsx +++ b/frontend/src/components/RenameChatDialog.tsx @@ -30,7 +30,6 @@ export function RenameChatDialog({ const [newTitle, setNewTitle] = useState(""); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const [success, setSuccess] = useState(false); // Set the initial value when dialog opens useEffect(() => { @@ -42,7 +41,6 @@ export function RenameChatDialog({ const resetForm = useCallback(() => { setNewTitle(currentTitle); setError(null); - setSuccess(false); setIsLoading(false); }, [currentTitle]); @@ -55,20 +53,25 @@ export function RenameChatDialog({ const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); - setSuccess(false); - if (!newTitle.trim()) { + const trimmedTitle = newTitle.trim(); + + if (!trimmedTitle) { setError("Chat title cannot be empty."); return; } + // Check if the new title is the same as the current title + if (trimmedTitle === currentTitle.trim()) { + setError("Please enter a different title."); + return; + } + setIsLoading(true); try { - await onRename(chatId, newTitle); - setSuccess(true); - setTimeout(() => { - onOpenChange(false); - }, 1000); // Close the dialog after 1 second + await onRename(chatId, trimmedTitle); + // Close dialog immediately on success + onOpenChange(false); } catch (error) { console.error("Failed to rename chat:", error); setError("Failed to rename chat. Please try again."); @@ -90,11 +93,6 @@ export function RenameChatDialog({ {error} )} - {success && ( - - Chat renamed successfully. - - )}
{ if (e.key === "Enter") { e.preventDefault(); - handleSubmit(e); + // Use requestSubmit for better form validation + const form = e.currentTarget.form; + if (form) form.requestSubmit(); } }} /> @@ -116,7 +116,7 @@ export function RenameChatDialog({ - diff --git a/frontend/src/routes/_auth.chat.$chatId.tsx b/frontend/src/routes/_auth.chat.$chatId.tsx index 9062abfee..70eec932e 100644 --- a/frontend/src/routes/_auth.chat.$chatId.tsx +++ b/frontend/src/routes/_auth.chat.$chatId.tsx @@ -11,6 +11,7 @@ import { Sidebar, SidebarToggle } from "@/components/Sidebar"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { InfoPopover } from "@/components/InfoPopover"; import { Button } from "@/components/ui/button"; +import { BillingStatus } from "@/billing/billingApi"; export const Route = createFileRoute("/_auth/chat/$chatId")({ component: ChatComponent @@ -180,15 +181,44 @@ function ChatComponent() { const sendMessage = useCallback( async (input: string) => { - // Inner function to generate chat title to avoid dependency issues + // Helper function to check if the user is on a free plan + function isUserOnFreePlan(): boolean { + try { + const billingStatus = queryClient.getQueryData(["billingStatus"]) as + | BillingStatus + | undefined; + + return ( + !billingStatus || + !billingStatus.product_name || + billingStatus.product_name.toLowerCase().includes("free") + ); + } catch (error) { + console.log("Error checking billing status, defaulting to free plan", error); + return true; // Default to free plan if there's an error + } + } + async function generateChatTitle(messages: ChatMessage[]): Promise { // Find the first user message const userMessage = messages.find((message) => message.role === "user"); if (!userMessage) return "New Chat"; + // Simple title generation - truncate first message to 50 chars + const simpleTitleFromMessage = userMessage.content.slice(0, 50).trim(); + + // For free plan users, just use the simple title + // For paid plans, try to generate AI title + if (isUserOnFreePlan()) { + console.log("Using simple title generation for free plan user"); + return simpleTitleFromMessage; + } + + // For paid plans, use LLM to generate a smart title try { + console.log("Using AI title generation for paid plan user"); // Get the user's first message, truncate if too long - const userContent = userMessage.content.slice(0, 1000); // Limit to 1000 chars to save tokens + const userContent = userMessage.content.slice(0, 500); // Reduced to 500 chars to optimize token usage // Use the OpenAI API to generate a concise title - use the same model as chat const stream = openai.beta.chat.completions.stream({ @@ -224,11 +254,11 @@ function ChatComponent() { .replace(/\n/g, " ") // Remove new lines .trim(); - return cleanTitle || userMessage.content.slice(0, 50); // Fallback to first 50 chars if generation fails + return cleanTitle || simpleTitleFromMessage; // Fallback to simple title if generation fails } catch (error) { console.error("Failed to generate chat title:", error); - // Fallback to first 50 characters of user message - return userMessage.content.slice(0, 50); + // Fallback to simple title method + return simpleTitleFromMessage; } } if (!input.trim() || !localChat) return; @@ -252,6 +282,42 @@ function ChatComponent() { setIsLoading(true); try { + // Start title generation early for paid users if needed + let titleGenerationPromise; + let title = localChat.title; + + if (title === "New Chat") { + const isFreePlan = isUserOnFreePlan(); + + if (!isFreePlan) { + console.log("Starting async AI title generation for paid user's chat"); + // Start title generation in parallel for paid users + titleGenerationPromise = generateChatTitle(newMessages).then((newTitle) => { + // Clean up the title + const cleanTitle = newTitle.replace(/"/g, "").replace(/\n/g, " "); + + // Update local chat with generated title immediately when available + setLocalChat((prev) => ({ + ...prev, + title: cleanTitle + })); + + return cleanTitle; + }); + } else { + console.log("Using simple title for free user's chat"); + // For free users, set the title synchronously + const newTitle = await generateChatTitle(newMessages); + title = newTitle.replace(/"/g, "").replace(/\n/g, " "); + + setLocalChat((prev) => ({ + ...prev, + title + })); + } + } + + // Stream the chat response (happens in parallel with title generation) const stream = openai.beta.chat.completions.stream({ model, messages: newMessages, @@ -303,26 +369,9 @@ function ChatComponent() { }); } - let title = localChat.title; - - // Generate and update the chat title, if the current title is "New Chat" - if (title === "New Chat") { - console.log("Generating AI title for chat"); - // Set a temporary title to indicate we're generating one - setLocalChat((prev) => ({ - ...prev, - title: "Generating title..." - })); - - const newTitle = await generateChatTitle(finalMessages); - // Get rid of quotes and any newlines in the title - title = newTitle.replace(/"/g, "").replace(/\n/g, " "); - - // Update local chat with generated title - setLocalChat((prev) => ({ - ...prev, - title: title - })); + // Wait for title generation to complete if we started it + if (titleGenerationPromise) { + title = await titleGenerationPromise; } const chatCompletion = await stream.finalChatCompletion(); @@ -332,7 +381,9 @@ function ChatComponent() { setUserPrompt(""); // React sucks and doesn't get the latest state - await persistChat({ ...localChat, title, messages: finalMessages }); + // Use current title from localChat which may have been updated asynchronously + const currentTitle = localChat.title === "New Chat" ? title : localChat.title; + await persistChat({ ...localChat, title: currentTitle, messages: finalMessages }); // Invalidate chat history to show the new title in the sidebar queryClient.invalidateQueries({ @@ -364,6 +415,9 @@ function ChatComponent() { setIsLoading(false); }, + // We intentionally don't include freshBillingStatus in the dependency array + // even though it's used in the closure to avoid re-creating the function + // on every billing status change [localChat, model, openai, persistChat, queryClient, setUserPrompt, chatId] ); @@ -382,9 +436,7 @@ function ChatComponent() { >
-

+

{localChat.title}

diff --git a/frontend/src/state/LocalStateContext.tsx b/frontend/src/state/LocalStateContext.tsx index a38f8bc3a..60b15b685 100644 --- a/frontend/src/state/LocalStateContext.tsx +++ b/frontend/src/state/LocalStateContext.tsx @@ -163,9 +163,8 @@ export const LocalStateProvider = ({ children }: { children: React.ReactNode }) async function renameChat(chatId: string, newTitle: string) { try { - // Get the current chat + // Get the current chat (getChatById already throws if chat not found) const chat = await getChatById(chatId); - if (!chat) throw new Error("Chat not found"); // Update the chat title chat.title = newTitle;