diff --git a/apps/start-template/src/components/app-sidebar.tsx b/apps/start-template/src/components/app-sidebar.tsx index 5027a78..86491e1 100644 --- a/apps/start-template/src/components/app-sidebar.tsx +++ b/apps/start-template/src/components/app-sidebar.tsx @@ -1,7 +1,7 @@ "use client"; import { ClientOnly } from "@tanstack/react-router"; -import { Building2, Home, Settings, Shield } from "lucide-react"; +import { Building2, Home, MessageSquare, Settings, Shield } from "lucide-react"; import type * as React from "react"; import { Suspense } from "react"; import { useTranslation } from "react-i18next"; @@ -50,6 +50,11 @@ export function AppSidebar({ ...props }: React.ComponentProps) { }, ], }, + { + title: "AI Chat", + url: "/chat", + icon: MessageSquare, + }, ...(isSuperAdmin ? [ { diff --git a/apps/start-template/src/lib/chat/use-resumable-chat.ts b/apps/start-template/src/lib/chat/use-resumable-chat.ts index e4d8af5..9f3e4f2 100644 --- a/apps/start-template/src/lib/chat/use-resumable-chat.ts +++ b/apps/start-template/src/lib/chat/use-resumable-chat.ts @@ -10,16 +10,37 @@ import { saveMessages, } from "./resumable-connection"; -const connection = () => +export type ChatProvider = "openai" | "anthropic" | "gemini"; + +export type ChatRuntimeOptions = { + provider: ChatProvider; + model: string; +}; + +const DEFAULT_CHAT_RUNTIME: ChatRuntimeOptions = { + provider: "openai", + model: "gpt-5-mini", +}; + +const connection = (getRuntime: () => ChatRuntimeOptions) => stream((messages, data) => { const conversationId = data?.conversationId ?? crypto.randomUUID(); - // Return async generator directly (not a Promise) + const provider = + (data?.provider as ChatProvider | undefined) ?? getRuntime().provider; + const model = (data?.model as string | undefined) ?? getRuntime().model; + return (async function* () { const response = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messages, conversationId, ...data }), + body: JSON.stringify({ + messages, + conversationId, + ...data, + provider, + model, + }), }); if (!response.ok) { @@ -30,12 +51,22 @@ const connection = () => })(); }); -export function useResumableChat() { +export function useResumableChat(initialRuntime?: Partial) { const [isResuming, setIsResuming] = useState(false); const [isInitialized, setIsInitialized] = useState(false); - const chat = useChat({ connection: connection() }); + const [runtime, setRuntime] = useState({ + ...DEFAULT_CHAT_RUNTIME, + ...initialRuntime, + }); + + const runtimeRef = useRef(runtime); + const chat = useChat({ connection: connection(() => runtimeRef.current) }); const messagesRef = useRef(chat.messages); + useEffect(() => { + runtimeRef.current = runtime; + }, [runtime]); + // Sync messages to storage and ref useEffect(() => { messagesRef.current = chat.messages; @@ -62,11 +93,10 @@ export function useResumableChat() { (async () => { try { - for await (const chunk of resumeStream( - "/api/chat", - controller.signal - )) { - if (chunk.type === "content" && chunk.delta) { + for await (const chunk of resumeStream("/api/chat", controller.signal)) { + const normalized = chunk as { type?: string; delta?: string }; + + if (normalized.type === "content" && normalized.delta) { const current = [...messagesRef.current]; const last = current.at(-1); if (last?.role === "assistant") { @@ -77,7 +107,7 @@ export function useResumableChat() { { ...last, parts: [ - { ...textPart, content: textPart.content + chunk.delta }, + { ...textPart, content: textPart.content + normalized.delta }, ], }, ]; @@ -104,6 +134,11 @@ export function useResumableChat() { return { ...chat, + runtime, + setRuntime, + setProvider: (provider: ChatProvider) => + setRuntime((prev) => ({ ...prev, provider })), + setModel: (model: string) => setRuntime((prev) => ({ ...prev, model })), isInitialized, isResuming, isStreaming: chat.isLoading || isResuming, diff --git a/apps/start-template/src/lib/env.server.ts b/apps/start-template/src/lib/env.server.ts index b6e5d49..092dd5b 100644 --- a/apps/start-template/src/lib/env.server.ts +++ b/apps/start-template/src/lib/env.server.ts @@ -18,6 +18,7 @@ export const env = createEnv({ VERCEL_PROJECT_PRODUCTION_URL: z.string().optional(), OPENAI_API_KEY: z.string().optional(), ANTHROPIC_API_KEY: z.string().optional(), + GOOGLE_GENERATIVE_AI_API_KEY: z.string().optional(), // Stripe configuration STRIPE_SECRET_KEY: z.string().optional(), STRIPE_WEBHOOK_SECRET: z.string().optional(), diff --git a/apps/start-template/src/routes/(dashboard)/chat/index.tsx b/apps/start-template/src/routes/(dashboard)/chat/index.tsx new file mode 100644 index 0000000..db29013 --- /dev/null +++ b/apps/start-template/src/routes/(dashboard)/chat/index.tsx @@ -0,0 +1,192 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { MessageSquareIcon } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { + Conversation, + ConversationContent, + ConversationEmptyState, + ConversationScrollButton, +} from "@/components/ai-elements/conversation"; +import { + Message, + MessageContent, + MessageResponse, +} from "@/components/ai-elements/message"; +import { + ModelSelector, + ModelSelectorContent, + ModelSelectorEmpty, + ModelSelectorGroup, + ModelSelectorInput, + ModelSelectorItem, + ModelSelectorList, + ModelSelectorLogo, + ModelSelectorName, + ModelSelectorTrigger, +} from "@/components/ai-elements/model-selector"; +import { + PromptInput, + PromptInputBody, + PromptInputFooter, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, +} from "@/components/ai-elements/prompt-input"; +import { Badge } from "@/components/ui/badge"; +import { + type ChatProvider, + useResumableChat, +} from "@/lib/chat/use-resumable-chat"; + +export const Route = createFileRoute("/(dashboard)/chat/" as any)({ + component: RouteComponent, +}); + +type ModelOption = { + provider: ChatProvider; + label: string; + model: string; +}; + +const MODELS: ModelOption[] = [ + { provider: "openai", model: "gpt-5-mini", label: "GPT-5 Mini" }, + { + provider: "anthropic", + model: "claude-3-5-haiku-latest", + label: "Claude 3.5 Haiku", + }, + { provider: "gemini", model: "gemini-2.0-flash", label: "Gemini 2.0 Flash" }, +]; + +function RouteComponent() { + const [modelPickerOpen, setModelPickerOpen] = useState(false); + const chat = useResumableChat(); + + const selectedLabel = useMemo(() => { + const found = MODELS.find( + (item) => + item.provider === chat.runtime.provider && item.model === chat.runtime.model + ); + return found?.label ?? `${chat.runtime.provider} ยท ${chat.runtime.model}`; + }, [chat.runtime.model, chat.runtime.provider]); + + return ( +
+
+
+

AI Chat

+

+ TanStack AI streaming with resumable chat. +

+
+ + + + {selectedLabel} + + + + + + No model found. + + {MODELS.map((item) => ( + { + chat.setRuntime({ + provider: item.provider, + model: item.model, + }); + setModelPickerOpen(false); + }} + value={`${item.label} ${item.provider} ${item.model}`} + > + + {item.label} + + {item.provider} + + + ))} + + + + +
+ + + + {chat.messages.length === 0 ? ( + } + title="No messages yet" + /> + ) : ( + chat.messages.map((message) => ( + + + {message.parts.map((part, index) => { + if (part.type !== "text") { + return null; + } + + const text = + (part as any).content ?? (part as any).text ?? ""; + + if (!text) { + return null; + } + + return ( + + {text} + + ); + })} + + + )) + )} + + + + + { + if (!text.trim() || chat.isStreaming) { + return; + } + + await (chat.sendMessage as any)(text, { + data: { + provider: chat.runtime.provider, + model: chat.runtime.model, + }, + }); + }} + > + + + + + + + + {chat.runtime.provider} + + {chat.runtime.model} + + + + + +
+ ); +} diff --git a/apps/start-template/src/routes/api/chat/index.ts b/apps/start-template/src/routes/api/chat/index.ts index 84496dc..7fbc843 100644 --- a/apps/start-template/src/routes/api/chat/index.ts +++ b/apps/start-template/src/routes/api/chat/index.ts @@ -1,10 +1,79 @@ import { chat, toServerSentEventsStream } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; +import { createAnthropicChat } from "@tanstack/ai-anthropic"; +import { createGeminiChat } from "@tanstack/ai-gemini"; +import { createOpenaiChat } from "@tanstack/ai-openai"; import { createFileRoute } from "@tanstack/react-router"; import { auth } from "@/lib/auth/auth"; import { getStreamContext } from "@/lib/chat/stream-context"; +type ChatProvider = "openai" | "anthropic" | "gemini"; + +type ChatRequestBody = { + messages: unknown[]; + conversationId?: string; + provider?: ChatProvider; + model?: string; + data?: { + provider?: ChatProvider; + model?: string; + }; +}; + +const DEFAULT_PROVIDER: ChatProvider = "openai"; + +const DEFAULT_MODELS: Record = { + openai: "gpt-5-mini", + anthropic: "claude-3-5-haiku-latest", + gemini: "gemini-2.0-flash", +}; + +function normalizeProvider(provider?: string): ChatProvider { + if (provider === "anthropic" || provider === "gemini") { + return provider; + } + return "openai"; +} + +function createAdapter(provider: ChatProvider, model?: string) { + const selectedModel = model?.trim() || DEFAULT_MODELS[provider]; + + if (provider === "anthropic") { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + throw new Error("ANTHROPIC_API_KEY is required for provider 'anthropic'"); + } + return createAnthropicChat( + selectedModel as Parameters[0], + apiKey + ); + } + + if (provider === "gemini") { + const apiKey = + process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? process.env.GEMINI_API_KEY; + if (!apiKey) { + throw new Error( + "GOOGLE_GENERATIVE_AI_API_KEY (or GEMINI_API_KEY) is required for provider 'gemini'" + ); + } + return createGeminiChat( + selectedModel as Parameters[0], + apiKey + ); + } + + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error("OPENAI_API_KEY is required for provider 'openai'"); + } + + return createOpenaiChat( + selectedModel as Parameters[0], + apiKey + ); +} + export const Route = createFileRoute("/api/chat/")({ server: { handlers: { @@ -12,7 +81,7 @@ export const Route = createFileRoute("/api/chat/")({ try { const [session, body, streamContext] = await Promise.all([ auth.api.getSession({ headers: request.headers }), - request.json() as Promise<{ messages: unknown[]; conversationId?: string }>, + request.json() as Promise, getStreamContext(), ]); @@ -21,13 +90,18 @@ export const Route = createFileRoute("/api/chat/")({ } const { messages, conversationId } = body; + const provider = normalizeProvider( + body.provider ?? body.data?.provider ?? DEFAULT_PROVIDER + ); + const model = body.model ?? body.data?.model; + const abortController = new AbortController(); const streamId = crypto.randomUUID(); const createBaseStream = () => { const baseStream = chat({ - adapter: openaiText("gpt-5-mini"), - messages, + adapter: createAdapter(provider, model), + messages: messages as any, conversationId, }); return toServerSentEventsStream(baseStream, abortController); @@ -36,19 +110,15 @@ export const Route = createFileRoute("/api/chat/")({ let responseStream: ReadableStream; if (streamContext) { - // Wrap with resumable stream for persistence const resumable = await streamContext.createNewResumableStream( streamId, () => { - // Convert Uint8Array stream to string stream for resumable-stream const baseStream = createBaseStream(); const decoder = new TextDecoder(); return baseStream.pipeThrough( new TransformStream({ transform(chunk, controller) { - controller.enqueue( - decoder.decode(chunk, { stream: true }) - ); + controller.enqueue(decoder.decode(chunk, { stream: true })); }, }) ); @@ -56,7 +126,6 @@ export const Route = createFileRoute("/api/chat/")({ ); if (resumable) { - // Convert back to Uint8Array for Response const encoder = new TextEncoder(); responseStream = resumable.pipeThrough( new TransformStream({ @@ -66,11 +135,9 @@ export const Route = createFileRoute("/api/chat/")({ }) ); } else { - // Stream already completed responseStream = createBaseStream(); } } else { - // No Redis, use base stream directly responseStream = createBaseStream(); } @@ -81,6 +148,8 @@ export const Route = createFileRoute("/api/chat/")({ Connection: "keep-alive", "X-Stream-Id": streamContext ? streamId : "", "X-Conversation-Id": conversationId ?? "", + "X-AI-Provider": provider, + "X-AI-Model": model ?? DEFAULT_MODELS[provider], }, }); } catch (error: unknown) { diff --git a/apps/start-template/src/routes/index.tsx b/apps/start-template/src/routes/index.tsx index f4aa380..63b5471 100644 --- a/apps/start-template/src/routes/index.tsx +++ b/apps/start-template/src/routes/index.tsx @@ -42,23 +42,40 @@ const AI_CODE_TABS = [ label: "Backend", language: "ts", code: `import { chat, toServerSentEventsStream } from "@tanstack/ai"; -import { openaiText } from "@tanstack/ai-openai"; +import { createAnthropicChat } from "@tanstack/ai-anthropic"; +import { createGeminiChat } from "@tanstack/ai-gemini"; +import { createOpenaiChat } from "@tanstack/ai-openai"; import { createFileRoute } from "@tanstack/react-router"; +type Provider = "openai" | "anthropic" | "gemini"; + +const adapters = { + openai: (model = "gpt-5-mini") => + createOpenaiChat(model as Parameters[0], process.env.OPENAI_API_KEY!), + anthropic: (model = "claude-3-5-haiku-latest") => + createAnthropicChat(model as Parameters[0], process.env.ANTHROPIC_API_KEY!), + gemini: (model = "gemini-2.0-flash") => + createGeminiChat(model as Parameters[0], process.env.GOOGLE_GENERATIVE_AI_API_KEY!), +}; + export const Route = createFileRoute("/api/chat/")({ server: { handlers: { POST: async ({ request }: { request: Request }) => { - const { messages, conversationId } = await request.json(); - const abortController = new AbortController(); + const { messages, conversationId, provider = "openai", model } = await request.json() as { + messages: unknown[]; + conversationId?: string; + provider?: Provider; + model?: string; + }; const stream = chat({ - adapter: openaiText("gpt-5-mini"), - messages, + adapter: adapters[provider](model), + messages: messages as any, conversationId, }); - return new Response(toServerSentEventsStream(stream, abortController), { + return new Response(toServerSentEventsStream(stream, new AbortController()), { headers: { "Content-Type": "text/event-stream" }, }); }, @@ -82,7 +99,11 @@ export function ChatPage() { @@ -455,7 +476,7 @@ function LandingPage() { className: "h-14 rounded-full border-border/60 px-10 font-medium text-lg transition-all hover:bg-accent/50", })} - href="https://github.com/CarlosZiegler/start-template" + href="https://github.com/CarlosZiegler/start-kit.dev" rel="noreferrer" target="_blank" > @@ -677,7 +698,7 @@ function LandingPage() { className: "h-16 rounded-full px-10 font-bold text-xl shadow-2xl shadow-primary/30", })} - href="https://github.com/CarlosZiegler/start-template" + href="https://github.com/CarlosZiegler/start-kit.dev" rel="noreferrer" target="_blank" > diff --git a/packages/cli/src/templates/env.template.ts b/packages/cli/src/templates/env.template.ts index 74fafeb..2598043 100644 --- a/packages/cli/src/templates/env.template.ts +++ b/packages/cli/src/templates/env.template.ts @@ -36,6 +36,7 @@ export function getEnvVarDefs(features: FeatureFlags): EnvVarDef[] { // AI { key: "OPENAI_API_KEY", required: false, feature: "ai" }, { key: "ANTHROPIC_API_KEY", required: false, feature: "ai" }, + { key: "GOOGLE_GENERATIVE_AI_API_KEY", required: false, feature: "ai" }, // Stripe { key: "STRIPE_SECRET_KEY", required: false, feature: "stripe" },