Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/start-template/src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -50,6 +50,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
},
],
},
{
title: "AI Chat",
url: "/chat",
icon: MessageSquare,
},
...(isSuperAdmin
? [
{
Expand Down
57 changes: 46 additions & 11 deletions apps/start-template/src/lib/chat/use-resumable-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -30,12 +51,22 @@ const connection = () =>
})();
});

export function useResumableChat() {
export function useResumableChat(initialRuntime?: Partial<ChatRuntimeOptions>) {
const [isResuming, setIsResuming] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const chat = useChat({ connection: connection() });
const [runtime, setRuntime] = useState<ChatRuntimeOptions>({
...DEFAULT_CHAT_RUNTIME,
...initialRuntime,
});

const runtimeRef = useRef(runtime);
const chat = useChat({ connection: connection(() => runtimeRef.current) });
const messagesRef = useRef<UIMessage[]>(chat.messages);

useEffect(() => {
runtimeRef.current = runtime;
}, [runtime]);

// Sync messages to storage and ref
useEffect(() => {
messagesRef.current = chat.messages;
Expand All @@ -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") {
Expand All @@ -77,7 +107,7 @@ export function useResumableChat() {
{
...last,
parts: [
{ ...textPart, content: textPart.content + chunk.delta },
{ ...textPart, content: textPart.content + normalized.delta },
],
},
];
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/start-template/src/lib/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
192 changes: 192 additions & 0 deletions apps/start-template/src/routes/(dashboard)/chat/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto flex h-[calc(100vh-9rem)] w-full max-w-5xl flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="font-semibold text-2xl">AI Chat</h1>
<p className="text-muted-foreground text-sm">
TanStack AI streaming with resumable chat.
</p>
</div>

<ModelSelector onOpenChange={setModelPickerOpen} open={modelPickerOpen}>
<ModelSelectorTrigger className="inline-flex items-center justify-start rounded-md border px-3 py-2 text-sm">
{selectedLabel}
</ModelSelectorTrigger>

<ModelSelectorContent title="Select model">
<ModelSelectorInput placeholder="Search model..." />
<ModelSelectorList>
<ModelSelectorEmpty>No model found.</ModelSelectorEmpty>
<ModelSelectorGroup heading="Models">
{MODELS.map((item) => (
<ModelSelectorItem
key={`${item.provider}:${item.model}`}
onSelect={() => {
chat.setRuntime({
provider: item.provider,
model: item.model,
});
setModelPickerOpen(false);
}}
value={`${item.label} ${item.provider} ${item.model}`}
>
<ModelSelectorLogo provider={item.provider} />
<ModelSelectorName>{item.label}</ModelSelectorName>
<span className="text-muted-foreground text-xs">
{item.provider}
</span>
</ModelSelectorItem>
))}
</ModelSelectorGroup>
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelector>
</div>

<Conversation className="min-h-0 flex-1 rounded-xl border">
<ConversationContent className="px-4 py-3">
{chat.messages.length === 0 ? (
<ConversationEmptyState
description="Start a conversation with your selected provider."
icon={<MessageSquareIcon className="size-10" />}
title="No messages yet"
/>
) : (
chat.messages.map((message) => (
<Message from={message.role} key={message.id}>
<MessageContent>
{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 (
<MessageResponse key={`${message.id}-${index}`}>
{text}
</MessageResponse>
);
})}
</MessageContent>
</Message>
))
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>

<PromptInput
className="w-full"
onSubmit={async ({ text }) => {
if (!text.trim() || chat.isStreaming) {
return;
}

await (chat.sendMessage as any)(text, {
data: {
provider: chat.runtime.provider,
model: chat.runtime.model,
},
});
}}
>
<PromptInputBody>
<PromptInputTextarea placeholder="Type your message..." />
</PromptInputBody>

<PromptInputFooter>
<PromptInputTools>
<Badge className="capitalize" variant="secondary">
{chat.runtime.provider}
</Badge>
<Badge variant="outline">{chat.runtime.model}</Badge>
</PromptInputTools>

<PromptInputSubmit
disabled={chat.isStreaming}
status={chat.isStreaming ? "streaming" : "ready"}
/>
</PromptInputFooter>
</PromptInput>
</div>
);
}
Loading
Loading