create-better-t-stack 3.10.0 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,21 +1,170 @@
1
+ {{#if (eq backend "convex")}}
1
2
  "use client";
2
3
 
3
- import { useChat } from "@ai-sdk/react";
4
- import { DefaultChatTransport } from "ai";
5
- import { Send } from "lucide-react";
4
+ import { api } from "@{{projectName}}/backend/convex/_generated/api";
5
+ import {
6
+ useUIMessages,
7
+ useSmoothText,
8
+ type UIMessage,
9
+ } from "@convex-dev/agent/react";
10
+ import { useMutation } from "convex/react";
11
+ import { Send, Loader2 } from "lucide-react";
6
12
  {{#if (eq webDeploy "alchemy")}}
7
13
  import dynamic from "next/dynamic";
14
+
15
+ const Streamdown = dynamic(
16
+ () => import("streamdown").then((mod) => ({ default: mod.Streamdown })),
17
+ {
18
+ loading: () => (
19
+ <div className="flex h-full items-center justify-center">
20
+ <div className="text-muted-foreground">Loading response...</div>
21
+ </div>
22
+ ),
23
+ ssr: false,
24
+ }
25
+ );
8
26
  {{else}}
9
- import { Response } from "@/components/response";
27
+ import { Streamdown } from "streamdown";
10
28
  {{/if}}
11
29
  import { useEffect, useRef, useState } from "react";
30
+
12
31
  import { Button } from "@/components/ui/button";
13
32
  import { Input } from "@/components/ui/input";
14
33
 
34
+ function MessageContent({
35
+ text,
36
+ isStreaming,
37
+ }: {
38
+ text: string;
39
+ isStreaming: boolean;
40
+ }) {
41
+ const [visibleText] = useSmoothText(text, {
42
+ startStreaming: isStreaming,
43
+ });
44
+ return <Streamdown>{visibleText}</Streamdown>;
45
+ }
46
+
47
+ export default function AIPage() {
48
+ const [input, setInput] = useState("");
49
+ const [threadId, setThreadId] = useState<string | null>(null);
50
+ const [isLoading, setIsLoading] = useState(false);
51
+ const messagesEndRef = useRef<HTMLDivElement>(null);
52
+
53
+ const createThread = useMutation(api.chat.createNewThread);
54
+ const sendMessage = useMutation(api.chat.sendMessage);
55
+
56
+ const { results: messages } = useUIMessages(
57
+ api.chat.listMessages,
58
+ threadId ? { threadId } : "skip",
59
+ { initialNumItems: 50, stream: true },
60
+ );
61
+
62
+ useEffect(() => {
63
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
64
+ }, [messages]);
65
+
66
+ const hasStreamingMessage = messages?.some(
67
+ (m: UIMessage) => m.status === "streaming",
68
+ );
69
+
70
+ const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
71
+ e.preventDefault();
72
+ const text = input.trim();
73
+ if (!text || isLoading) return;
74
+
75
+ setIsLoading(true);
76
+ setInput("");
77
+
78
+ try {
79
+ let currentThreadId = threadId;
80
+ if (!currentThreadId) {
81
+ currentThreadId = await createThread();
82
+ setThreadId(currentThreadId);
83
+ }
84
+
85
+ await sendMessage({ threadId: currentThreadId, prompt: text });
86
+ } catch (error) {
87
+ console.error("Failed to send message:", error);
88
+ } finally {
89
+ setIsLoading(false);
90
+ }
91
+ };
92
+
93
+ return (
94
+ <div className="grid grid-rows-[1fr_auto] overflow-hidden w-full mx-auto p-4">
95
+ <div className="overflow-y-auto space-y-4 pb-4">
96
+ {!messages || messages.length === 0 ? (
97
+ <div className="text-center text-muted-foreground mt-8">
98
+ Ask me anything to get started!
99
+ </div>
100
+ ) : (
101
+ messages.map((message: UIMessage) => (
102
+ <div
103
+ key={message.key}
104
+ className={`p-3 rounded-lg ${
105
+ message.role === "user"
106
+ ? "bg-primary/10 ml-8"
107
+ : "bg-secondary/20 mr-8"
108
+ }`}
109
+ >
110
+ <p className="text-sm font-semibold mb-1">
111
+ {message.role === "user" ? "You" : "AI Assistant"}
112
+ </p>
113
+ <MessageContent
114
+ text={message.text ?? ""}
115
+ isStreaming={message.status === "streaming"}
116
+ />
117
+ </div>
118
+ ))
119
+ )}
120
+ {isLoading && !hasStreamingMessage && (
121
+ <div className="p-3 rounded-lg bg-secondary/20 mr-8">
122
+ <p className="text-sm font-semibold mb-1">AI Assistant</p>
123
+ <div className="flex items-center gap-2 text-muted-foreground">
124
+ <Loader2 className="h-4 w-4 animate-spin" />
125
+ <span>Thinking...</span>
126
+ </div>
127
+ </div>
128
+ )}
129
+ <div ref={messagesEndRef} />
130
+ </div>
131
+
132
+ <form
133
+ onSubmit={handleSubmit}
134
+ className="w-full flex items-center space-x-2 pt-2 border-t"
135
+ >
136
+ <Input
137
+ name="prompt"
138
+ value={input}
139
+ onChange={(e) => setInput(e.target.value)}
140
+ placeholder="Type your message..."
141
+ className="flex-1"
142
+ autoComplete="off"
143
+ autoFocus
144
+ disabled={isLoading}
145
+ />
146
+ <Button type="submit" size="icon" disabled={isLoading || !input.trim()}>
147
+ {isLoading ? (
148
+ <Loader2 className="h-4 w-4 animate-spin" />
149
+ ) : (
150
+ <Send size={18} />
151
+ )}
152
+ </Button>
153
+ </form>
154
+ </div>
155
+ );
156
+ }
157
+ {{else}}
158
+ "use client";
159
+
160
+ import { useChat } from "@ai-sdk/react";
161
+ import { DefaultChatTransport } from "ai";
162
+ import { Send } from "lucide-react";
15
163
  {{#if (eq webDeploy "alchemy")}}
16
- const Response = dynamic(
17
- () =>
18
- import("@/components/response").then((mod) => ({ default: mod.Response })),
164
+ import dynamic from "next/dynamic";
165
+
166
+ const Streamdown = dynamic(
167
+ () => import("streamdown").then((mod) => ({ default: mod.Streamdown })),
19
168
  {
20
169
  loading: () => (
21
170
  <div className="flex h-full items-center justify-center">
@@ -25,11 +174,17 @@ const Response = dynamic(
25
174
  ssr: false,
26
175
  }
27
176
  );
177
+ {{else}}
178
+ import { Streamdown } from "streamdown";
28
179
  {{/if}}
180
+ import { useEffect, useRef, useState } from "react";
181
+
182
+ import { Button } from "@/components/ui/button";
183
+ import { Input } from "@/components/ui/input";
29
184
 
30
185
  export default function AIPage() {
31
186
  const [input, setInput] = useState("");
32
- const { messages, sendMessage } = useChat({
187
+ const { messages, sendMessage, status } = useChat({
33
188
  transport: new DefaultChatTransport({
34
189
  api: {{#if (eq backend "self")}}"/api/ai"{{else}}`${process.env.NEXT_PUBLIC_SERVER_URL}/ai`{{/if}},
35
190
  }),
@@ -71,7 +226,14 @@ export default function AIPage() {
71
226
  </p>
72
227
  {message.parts?.map((part, index) => {
73
228
  if (part.type === "text") {
74
- return <Response key={index}>{part.text}</Response>;
229
+ return (
230
+ <Streamdown
231
+ key={index}
232
+ isAnimating={status === "streaming" && message.role === "assistant"}
233
+ >
234
+ {part.text}
235
+ </Streamdown>
236
+ );
75
237
  }
76
238
  return null;
77
239
  })}
@@ -101,3 +263,4 @@ export default function AIPage() {
101
263
  </div>
102
264
  );
103
265
  }
266
+ {{/if}}
@@ -1,14 +1,156 @@
1
+ {{#if (eq backend "convex")}}
2
+ import { api } from "@{{projectName}}/backend/convex/_generated/api";
3
+ import {
4
+ useUIMessages,
5
+ useSmoothText,
6
+ type UIMessage,
7
+ } from "@convex-dev/agent/react";
8
+ import { useMutation } from "convex/react";
9
+ import { Send, Loader2 } from "lucide-react";
10
+ import React, { useRef, useEffect, useState } from "react";
11
+ import { Streamdown } from "streamdown";
12
+
13
+ import { Button } from "@/components/ui/button";
14
+ import { Input } from "@/components/ui/input";
15
+
16
+ function MessageContent({
17
+ text,
18
+ isStreaming,
19
+ }: {
20
+ text: string;
21
+ isStreaming: boolean;
22
+ }) {
23
+ const [visibleText] = useSmoothText(text, {
24
+ startStreaming: isStreaming,
25
+ });
26
+ return <Streamdown>{visibleText}</Streamdown>;
27
+ }
28
+
29
+ const AI: React.FC = () => {
30
+ const [input, setInput] = useState("");
31
+ const [threadId, setThreadId] = useState<string | null>(null);
32
+ const [isLoading, setIsLoading] = useState(false);
33
+ const messagesEndRef = useRef<HTMLDivElement>(null);
34
+
35
+ const createThread = useMutation(api.chat.createNewThread);
36
+ const sendMessage = useMutation(api.chat.sendMessage);
37
+
38
+ const { results: messages } = useUIMessages(
39
+ api.chat.listMessages,
40
+ threadId ? { threadId } : "skip",
41
+ { initialNumItems: 50, stream: true },
42
+ );
43
+
44
+ useEffect(() => {
45
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
46
+ }, [messages]);
47
+
48
+ const hasStreamingMessage = messages?.some(
49
+ (m: UIMessage) => m.status === "streaming",
50
+ );
51
+
52
+ const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
53
+ e.preventDefault();
54
+ const text = input.trim();
55
+ if (!text || isLoading) return;
56
+
57
+ setIsLoading(true);
58
+ setInput("");
59
+
60
+ try {
61
+ let currentThreadId = threadId;
62
+ if (!currentThreadId) {
63
+ currentThreadId = await createThread();
64
+ setThreadId(currentThreadId);
65
+ }
66
+
67
+ await sendMessage({ threadId: currentThreadId, prompt: text });
68
+ } catch (error) {
69
+ console.error("Failed to send message:", error);
70
+ } finally {
71
+ setIsLoading(false);
72
+ }
73
+ };
74
+
75
+ return (
76
+ <div className="grid grid-rows-[1fr_auto] overflow-hidden w-full mx-auto p-4">
77
+ <div className="overflow-y-auto space-y-4 pb-4">
78
+ {!messages || messages.length === 0 ? (
79
+ <div className="text-center text-muted-foreground mt-8">
80
+ Ask me anything to get started!
81
+ </div>
82
+ ) : (
83
+ messages.map((message: UIMessage) => (
84
+ <div
85
+ key={message.key}
86
+ className={`p-3 rounded-lg ${
87
+ message.role === "user"
88
+ ? "bg-primary/10 ml-8"
89
+ : "bg-secondary/20 mr-8"
90
+ }`}
91
+ >
92
+ <p className="text-sm font-semibold mb-1">
93
+ {message.role === "user" ? "You" : "AI Assistant"}
94
+ </p>
95
+ <MessageContent
96
+ text={message.text ?? ""}
97
+ isStreaming={message.status === "streaming"}
98
+ />
99
+ </div>
100
+ ))
101
+ )}
102
+ {isLoading && !hasStreamingMessage && (
103
+ <div className="p-3 rounded-lg bg-secondary/20 mr-8">
104
+ <p className="text-sm font-semibold mb-1">AI Assistant</p>
105
+ <div className="flex items-center gap-2 text-muted-foreground">
106
+ <Loader2 className="h-4 w-4 animate-spin" />
107
+ <span>Thinking...</span>
108
+ </div>
109
+ </div>
110
+ )}
111
+ <div ref={messagesEndRef} />
112
+ </div>
113
+
114
+ <form
115
+ onSubmit={handleSubmit}
116
+ className="w-full flex items-center space-x-2 pt-2 border-t"
117
+ >
118
+ <Input
119
+ name="prompt"
120
+ value={input}
121
+ onChange={(e) => setInput(e.target.value)}
122
+ placeholder="Type your message..."
123
+ className="flex-1"
124
+ autoComplete="off"
125
+ autoFocus
126
+ disabled={isLoading}
127
+ />
128
+ <Button type="submit" size="icon" disabled={isLoading || !input.trim()}>
129
+ {isLoading ? (
130
+ <Loader2 className="h-4 w-4 animate-spin" />
131
+ ) : (
132
+ <Send size={18} />
133
+ )}
134
+ </Button>
135
+ </form>
136
+ </div>
137
+ );
138
+ };
139
+
140
+ export default AI;
141
+ {{else}}
1
142
  import React, { useRef, useEffect, useState } from "react";
2
143
  import { useChat } from "@ai-sdk/react";
3
144
  import { DefaultChatTransport } from "ai";
4
- import { Input } from "@/components/ui/input";
5
- import { Button } from "@/components/ui/button";
6
145
  import { Send } from "lucide-react";
7
- import { Response } from "@/components/response";
146
+ import { Streamdown } from "streamdown";
147
+
148
+ import { Button } from "@/components/ui/button";
149
+ import { Input } from "@/components/ui/input";
8
150
 
9
151
  const AI: React.FC = () => {
10
152
  const [input, setInput] = useState("");
11
- const { messages, sendMessage } = useChat({
153
+ const { messages, sendMessage, status } = useChat({
12
154
  transport: new DefaultChatTransport({
13
155
  api: `${import.meta.env.VITE_SERVER_URL}/ai`,
14
156
  }),
@@ -50,7 +192,14 @@ const AI: React.FC = () => {
50
192
  </p>
51
193
  {message.parts?.map((part, index) => {
52
194
  if (part.type === "text") {
53
- return <Response key={index}>{part.text}</Response>;
195
+ return (
196
+ <Streamdown
197
+ key={index}
198
+ isAnimating={status === "streaming" && message.role === "assistant"}
199
+ >
200
+ {part.text}
201
+ </Streamdown>
202
+ );
54
203
  }
55
204
  return null;
56
205
  })}
@@ -81,4 +230,5 @@ const AI: React.FC = () => {
81
230
  );
82
231
  };
83
232
 
84
- export default AI;
233
+ export default AI;
234
+ {{/if}}
@@ -1,3 +1,147 @@
1
+ {{#if (eq backend "convex")}}
2
+ import { api } from "@{{projectName}}/backend/convex/_generated/api";
3
+ import {
4
+ useUIMessages,
5
+ useSmoothText,
6
+ type UIMessage,
7
+ } from "@convex-dev/agent/react";
8
+ import { createFileRoute } from "@tanstack/react-router";
9
+ import { useMutation } from "convex/react";
10
+ import { Send, Loader2 } from "lucide-react";
11
+ import { useRef, useEffect, useState } from "react";
12
+ import { Streamdown } from "streamdown";
13
+
14
+ import { Button } from "@/components/ui/button";
15
+ import { Input } from "@/components/ui/input";
16
+
17
+ export const Route = createFileRoute("/ai")({
18
+ component: RouteComponent,
19
+ });
20
+
21
+ function MessageContent({
22
+ text,
23
+ isStreaming,
24
+ }: {
25
+ text: string;
26
+ isStreaming: boolean;
27
+ }) {
28
+ const [visibleText] = useSmoothText(text, {
29
+ startStreaming: isStreaming,
30
+ });
31
+ return <Streamdown>{visibleText}</Streamdown>;
32
+ }
33
+
34
+ function RouteComponent() {
35
+ const [input, setInput] = useState("");
36
+ const [threadId, setThreadId] = useState<string | null>(null);
37
+ const [isLoading, setIsLoading] = useState(false);
38
+ const messagesEndRef = useRef<HTMLDivElement>(null);
39
+
40
+ const createThread = useMutation(api.chat.createNewThread);
41
+ const sendMessage = useMutation(api.chat.sendMessage);
42
+
43
+ const { results: messages } = useUIMessages(
44
+ api.chat.listMessages,
45
+ threadId ? { threadId } : "skip",
46
+ { initialNumItems: 50, stream: true },
47
+ );
48
+
49
+ useEffect(() => {
50
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
51
+ }, [messages]);
52
+
53
+ const hasStreamingMessage = messages?.some(
54
+ (m: UIMessage) => m.status === "streaming",
55
+ );
56
+
57
+ const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
58
+ e.preventDefault();
59
+ const text = input.trim();
60
+ if (!text || isLoading) return;
61
+
62
+ setIsLoading(true);
63
+ setInput("");
64
+
65
+ try {
66
+ let currentThreadId = threadId;
67
+ if (!currentThreadId) {
68
+ currentThreadId = await createThread();
69
+ setThreadId(currentThreadId);
70
+ }
71
+
72
+ await sendMessage({ threadId: currentThreadId, prompt: text });
73
+ } catch (error) {
74
+ console.error("Failed to send message:", error);
75
+ } finally {
76
+ setIsLoading(false);
77
+ }
78
+ };
79
+
80
+ return (
81
+ <div className="grid grid-rows-[1fr_auto] overflow-hidden w-full mx-auto p-4">
82
+ <div className="overflow-y-auto space-y-4 pb-4">
83
+ {!messages || messages.length === 0 ? (
84
+ <div className="text-center text-muted-foreground mt-8">
85
+ Ask me anything to get started!
86
+ </div>
87
+ ) : (
88
+ messages.map((message: UIMessage) => (
89
+ <div
90
+ key={message.key}
91
+ className={`p-3 rounded-lg ${
92
+ message.role === "user"
93
+ ? "bg-primary/10 ml-8"
94
+ : "bg-secondary/20 mr-8"
95
+ }`}
96
+ >
97
+ <p className="text-sm font-semibold mb-1">
98
+ {message.role === "user" ? "You" : "AI Assistant"}
99
+ </p>
100
+ <MessageContent
101
+ text={message.text ?? ""}
102
+ isStreaming={message.status === "streaming"}
103
+ />
104
+ </div>
105
+ ))
106
+ )}
107
+ {isLoading && !hasStreamingMessage && (
108
+ <div className="p-3 rounded-lg bg-secondary/20 mr-8">
109
+ <p className="text-sm font-semibold mb-1">AI Assistant</p>
110
+ <div className="flex items-center gap-2 text-muted-foreground">
111
+ <Loader2 className="h-4 w-4 animate-spin" />
112
+ <span>Thinking...</span>
113
+ </div>
114
+ </div>
115
+ )}
116
+ <div ref={messagesEndRef} />
117
+ </div>
118
+
119
+ <form
120
+ onSubmit={handleSubmit}
121
+ className="w-full flex items-center space-x-2 pt-2 border-t"
122
+ >
123
+ <Input
124
+ name="prompt"
125
+ value={input}
126
+ onChange={(e) => setInput(e.target.value)}
127
+ placeholder="Type your message..."
128
+ className="flex-1"
129
+ autoComplete="off"
130
+ autoFocus
131
+ disabled={isLoading}
132
+ />
133
+ <Button type="submit" size="icon" disabled={isLoading || !input.trim()}>
134
+ {isLoading ? (
135
+ <Loader2 className="h-4 w-4 animate-spin" />
136
+ ) : (
137
+ <Send size={18} />
138
+ )}
139
+ </Button>
140
+ </form>
141
+ </div>
142
+ );
143
+ }
144
+ {{else}}
1
145
  import { createFileRoute } from "@tanstack/react-router";
2
146
  import { useChat } from "@ai-sdk/react";
3
147
  import { DefaultChatTransport } from "ai";
@@ -5,7 +149,7 @@ import { Input } from "@/components/ui/input";
5
149
  import { Button } from "@/components/ui/button";
6
150
  import { Send } from "lucide-react";
7
151
  import { useRef, useEffect, useState } from "react";
8
- import { Response } from "@/components/response";
152
+ import { Streamdown } from "streamdown";
9
153
 
10
154
  export const Route = createFileRoute("/ai")({
11
155
  component: RouteComponent,
@@ -13,9 +157,9 @@ export const Route = createFileRoute("/ai")({
13
157
 
14
158
  function RouteComponent() {
15
159
  const [input, setInput] = useState("");
16
- const { messages, sendMessage } = useChat({
160
+ const { messages, sendMessage, status } = useChat({
17
161
  transport: new DefaultChatTransport({
18
- api: `${import.meta.env.VITE_SERVER_URL}/ai`,
162
+ api: {{#if (eq backend "self")}}"/api/ai"{{else}}`${import.meta.env.VITE_SERVER_URL}/ai`{{/if}},
19
163
  }),
20
164
  });
21
165
 
@@ -55,7 +199,14 @@ function RouteComponent() {
55
199
  </p>
56
200
  {message.parts?.map((part, index) => {
57
201
  if (part.type === "text") {
58
- return <Response key={index}>{part.text}</Response>;
202
+ return (
203
+ <Streamdown
204
+ key={index}
205
+ isAnimating={status === "streaming" && message.role === "assistant"}
206
+ >
207
+ {part.text}
208
+ </Streamdown>
209
+ );
59
210
  }
60
211
  return null;
61
212
  })}
@@ -85,3 +236,4 @@ function RouteComponent() {
85
236
  </div>
86
237
  );
87
238
  }
239
+ {{/if}}