From d8f1751fbae2477a3019a747184e8cb3600c907f Mon Sep 17 00:00:00 2001 From: Mihir Penugonda Date: Sat, 7 Jun 2025 19:57:46 -0700 Subject: [PATCH 1/3] enable all keys to be optional in onboarding --- app/api/completion/route.ts | 35 +++++++++++++++++++++-------- frontend/components/APIKeyForm.tsx | 13 ++++++----- frontend/hooks/useMessageSummary.ts | 10 +++++---- frontend/stores/APIKeyStore.ts | 12 +++++++++- 4 files changed, 51 insertions(+), 19 deletions(-) diff --git a/app/api/completion/route.ts b/app/api/completion/route.ts index 93cd9af..20a3d65 100644 --- a/app/api/completion/route.ts +++ b/app/api/completion/route.ts @@ -1,4 +1,5 @@ import { createGoogleGenerativeAI } from '@ai-sdk/google'; +import { createOpenAI } from '@ai-sdk/openai'; import { generateText } from 'ai'; import { headers } from 'next/headers'; import { NextResponse } from 'next/server'; @@ -6,25 +7,41 @@ import { NextResponse } from 'next/server'; export async function POST(req: Request) { const headersList = await headers(); const googleApiKey = headersList.get('X-Google-API-Key'); + const openaiApiKey = headersList.get('X-OpenAI-API-Key'); + const openrouterApiKey = headersList.get('X-OpenRouter-API-Key'); - if (!googleApiKey) { + const { prompt, isTitle, messageId, threadId } = await req.json(); + + let model; + + if (googleApiKey) { + const google = createGoogleGenerativeAI({ + apiKey: googleApiKey, + }); + model = google('gemini-2.5-flash-preview-04-17'); + } else if (openaiApiKey) { + const openai = createOpenAI({ + apiKey: openaiApiKey, + }); + model = openai('gpt-4.1-mini'); + } else if (openrouterApiKey) { + const openrouter = createOpenAI({ + baseURL: 'https://openrouter.ai/api/v1', + apiKey: openrouterApiKey, + }); + model = openrouter('deepseek/deepseek-chat-v3-0324:free'); + } else { return NextResponse.json( { - error: 'Google API key is required to enable chat title generation.', + error: 'At least one API key is required to enable chat title generation.', }, { status: 400 } ); } - const google = createGoogleGenerativeAI({ - apiKey: googleApiKey, - }); - - const { prompt, isTitle, messageId, threadId } = await req.json(); - try { const { text: title } = await generateText({ - model: google('gemini-2.5-flash-preview-04-17'), + model, system: `\n - you will generate a short title based on the first message a user begins a conversation with - ensure it is not more than 80 characters long diff --git a/frontend/components/APIKeyForm.tsx b/frontend/components/APIKeyForm.tsx index 86336e0..19b15f4 100644 --- a/frontend/components/APIKeyForm.tsx +++ b/frontend/components/APIKeyForm.tsx @@ -18,12 +18,16 @@ import { useAPIKeyStore } from '@/frontend/stores/APIKeyStore'; import { Badge } from './ui/badge'; const formSchema = z.object({ - google: z.string().trim().min(1, { - message: 'Google API key is required for Title Generation', - }), + google: z.string().trim().optional(), openrouter: z.string().trim().optional(), openai: z.string().trim().optional(), -}); +}).refine( + (data) => data.google || data.openrouter || data.openai, + { + message: 'At least one API key is required', + path: ['google'], + } +); type FormValues = z.infer; @@ -81,7 +85,6 @@ const Form = () => { placeholder="AIza..." register={register} error={errors.google} - required /> { - const getKey = useAPIKeyStore((state) => state.getKey); + const getFirstAvailableKey = useAPIKeyStore((state) => state.getFirstAvailableKey); + + const availableKey = getFirstAvailableKey(); const { complete, isLoading } = useCompletion({ api: '/api/completion', - ...(getKey('google') && { - headers: { 'X-Google-API-Key': getKey('google')! }, - }), + headers: availableKey ? { + [`X-${availableKey.provider === 'google' ? 'Google' : availableKey.provider === 'openai' ? 'OpenAI' : 'OpenRouter'}-API-Key`]: availableKey.key + } : {}, onResponse: async (response) => { try { const payload: MessageSummaryPayload = await response.json(); diff --git a/frontend/stores/APIKeyStore.ts b/frontend/stores/APIKeyStore.ts index 2f92be9..2a6d51a 100644 --- a/frontend/stores/APIKeyStore.ts +++ b/frontend/stores/APIKeyStore.ts @@ -11,6 +11,7 @@ type APIKeyStore = { setKeys: (newKeys: Partial) => void; hasRequiredKeys: () => boolean; getKey: (provider: Provider) => string | null; + getFirstAvailableKey: () => { provider: Provider; key: string } | null; }; type StoreWithPersist = Mutate< @@ -48,13 +49,22 @@ export const useAPIKeyStore = create()( }, hasRequiredKeys: () => { - return !!get().keys.google; + const keys = get().keys; + return !!keys.google || !!keys.openrouter || !!keys.openai; }, getKey: (provider) => { const key = get().keys[provider]; return key ? key : null; }, + + getFirstAvailableKey: () => { + const keys = get().keys; + if (keys.google) return { provider: 'google' as Provider, key: keys.google }; + if (keys.openai) return { provider: 'openai' as Provider, key: keys.openai }; + if (keys.openrouter) return { provider: 'openrouter' as Provider, key: keys.openrouter }; + return null; + }, }), { name: 'api-keys', From b15ce29a12e44e9775be28b3839fb86f1f40e406 Mon Sep 17 00:00:00 2001 From: Mihir Penugonda Date: Sat, 7 Jun 2025 20:10:36 -0700 Subject: [PATCH 2/3] cleanup + add loader when title is generating --- app/api/completion/route.ts | 33 +++++++++---------- frontend/components/ChatInput.tsx | 3 ++ frontend/components/ChatSidebar.tsx | 11 +++++-- frontend/hooks/useAutoSelectModel.ts | 28 +++++++++++++++++ frontend/hooks/useMessageSummary.ts | 47 +++++++++++++++++++++------- frontend/stores/TitleLoadingStore.ts | 27 ++++++++++++++++ 6 files changed, 120 insertions(+), 29 deletions(-) create mode 100644 frontend/hooks/useAutoSelectModel.ts create mode 100644 frontend/stores/TitleLoadingStore.ts diff --git a/app/api/completion/route.ts b/app/api/completion/route.ts index 20a3d65..91615d2 100644 --- a/app/api/completion/route.ts +++ b/app/api/completion/route.ts @@ -1,39 +1,40 @@ -import { createGoogleGenerativeAI } from '@ai-sdk/google'; -import { createOpenAI } from '@ai-sdk/openai'; -import { generateText } from 'ai'; -import { headers } from 'next/headers'; -import { NextResponse } from 'next/server'; +import { createGoogleGenerativeAI } from "@ai-sdk/google"; +import { createOpenAI } from "@ai-sdk/openai"; +import { generateText } from "ai"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; export async function POST(req: Request) { const headersList = await headers(); - const googleApiKey = headersList.get('X-Google-API-Key'); - const openaiApiKey = headersList.get('X-OpenAI-API-Key'); - const openrouterApiKey = headersList.get('X-OpenRouter-API-Key'); + const googleApiKey = headersList.get("X-Google-API-Key"); + const openaiApiKey = headersList.get("X-OpenAI-API-Key"); + const openrouterApiKey = headersList.get("X-OpenRouter-API-Key"); const { prompt, isTitle, messageId, threadId } = await req.json(); let model; - + if (googleApiKey) { const google = createGoogleGenerativeAI({ apiKey: googleApiKey, }); - model = google('gemini-2.5-flash-preview-04-17'); + model = google("gemini-2.5-flash-preview-04-17"); } else if (openaiApiKey) { const openai = createOpenAI({ apiKey: openaiApiKey, }); - model = openai('gpt-4.1-mini'); + model = openai("gpt-4.1-mini"); } else if (openrouterApiKey) { const openrouter = createOpenAI({ - baseURL: 'https://openrouter.ai/api/v1', + baseURL: "https://openrouter.ai/api/v1", apiKey: openrouterApiKey, }); - model = openrouter('deepseek/deepseek-chat-v3-0324:free'); + model = openrouter("deepseek/deepseek-chat-v3-0324:free"); } else { return NextResponse.json( { - error: 'At least one API key is required to enable chat title generation.', + error: + "At least one API key is required to enable chat title generation.", }, { status: 400 } ); @@ -53,9 +54,9 @@ export async function POST(req: Request) { return NextResponse.json({ title, isTitle, messageId, threadId }); } catch (error) { - console.error('Failed to generate title:', error); + console.error("Failed to generate title:", error); return NextResponse.json( - { error: 'Failed to generate title' }, + { error: "Failed to generate title" }, { status: 500 } ); } diff --git a/frontend/components/ChatInput.tsx b/frontend/components/ChatInput.tsx index 0630f3b..9cffa00 100644 --- a/frontend/components/ChatInput.tsx +++ b/frontend/components/ChatInput.tsx @@ -23,6 +23,8 @@ import { v4 as uuidv4 } from 'uuid'; import { StopIcon } from './ui/icons'; import { toast } from 'sonner'; import { useMessageSummary } from '../hooks/useMessageSummary'; +import { useAutoSelectModel } from '../hooks/useAutoSelectModel'; +import { useTitleLoadingStore } from '../stores/TitleLoadingStore'; interface ChatInputProps { threadId: string; @@ -59,6 +61,7 @@ function PureChatInput({ stop, }: ChatInputProps) { const canChat = useAPIKeyStore((state) => state.hasRequiredKeys()); + useAutoSelectModel(); const { textareaRef, adjustHeight } = useAutoResizeTextarea({ minHeight: 72, diff --git a/frontend/components/ChatSidebar.tsx b/frontend/components/ChatSidebar.tsx index 8181d5f..ca65cff 100644 --- a/frontend/components/ChatSidebar.tsx +++ b/frontend/components/ChatSidebar.tsx @@ -13,14 +13,16 @@ import { Button, buttonVariants } from './ui/button'; import { deleteThread, getThreads } from '@/frontend/dexie/queries'; import { useLiveQuery } from 'dexie-react-hooks'; import { Link, useNavigate, useParams } from 'react-router'; -import { X } from 'lucide-react'; +import { X, Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { memo } from 'react'; +import { useTitleLoadingStore } from '@/frontend/stores/TitleLoadingStore'; export default function ChatSidebar() { const { id } = useParams(); const navigate = useNavigate(); const threads = useLiveQuery(() => getThreads(), []); + const isLoading = useTitleLoadingStore((state) => state.isLoading); return ( @@ -45,7 +47,12 @@ export default function ChatSidebar() { navigate(`/chat/${thread.id}`); }} > - {thread.title} + + {thread.title} + {isLoading(thread.id) && ( + + )} +