Skip to content

Commit 2954416

Browse files
committed
feat: new TanChat with AI assistant
1 parent c5cbf41 commit 2954416

26 files changed

+594
-775
lines changed

src/create-app.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -712,7 +712,7 @@ ${environment.getErrors().join('\n')}`
712712
startCommand = `deno ${isAddOnEnabled('start') ? 'task dev' : 'start'}`
713713
}
714714

715-
outro(`Created your TanStack app in '${basename(targetDir)}'.
715+
outro(`Your TanStack app is ready in '${basename(targetDir)}'.
716716
717717
Use the following commands to start your app:
718718
% cd ${options.projectName}
476 KB
Loading
647 KB
Loading
316 KB
Loading
425 KB
Loading
391 KB
Loading
385 KB
Loading

templates/react/example/tanchat/assets/src/components/demo.SettingsDialog.tsx

Lines changed: 0 additions & 148 deletions
This file was deleted.
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { useEffect, useRef } from 'react'
2+
import { useStore } from '@tanstack/react-store'
3+
import { Send, X } from 'lucide-react'
4+
import ReactMarkdown from 'react-markdown'
5+
import rehypeRaw from 'rehype-raw'
6+
import rehypeSanitize from 'rehype-sanitize'
7+
import rehypeHighlight from 'rehype-highlight'
8+
import remarkGfm from 'remark-gfm'
9+
import { useChat } from '@ai-sdk/react'
10+
import { genAIResponse } from '../utils/demo.ai'
11+
12+
import { showAIAssistant } from '../store/example-assistant'
13+
import GuitarRecommendation from './example-GuitarRecommendation'
14+
15+
import type { UIMessage } from 'ai'
16+
17+
function Messages({ messages }: { messages: Array<UIMessage> }) {
18+
const messagesContainerRef = useRef<HTMLDivElement>(null)
19+
20+
useEffect(() => {
21+
if (messagesContainerRef.current) {
22+
messagesContainerRef.current.scrollTop =
23+
messagesContainerRef.current.scrollHeight
24+
}
25+
}, [messages])
26+
27+
if (!messages.length) {
28+
return (
29+
<div className="flex-1 flex items-center justify-center text-gray-400 text-sm">
30+
Ask me anything! I'm here to help.
31+
</div>
32+
)
33+
}
34+
35+
return (
36+
<div ref={messagesContainerRef} className="flex-1 overflow-y-auto">
37+
{messages.map(({ id, role, content, parts }) => (
38+
<div
39+
key={id}
40+
className={`py-3 ${
41+
role === 'assistant'
42+
? 'bg-gradient-to-r from-orange-500/5 to-red-600/5'
43+
: 'bg-transparent'
44+
}`}
45+
>
46+
{content.length > 0 && (
47+
<div className="flex items-start gap-2 px-4">
48+
{role === 'assistant' ? (
49+
<div className="w-6 h-6 rounded-lg bg-gradient-to-r from-orange-500 to-red-600 flex items-center justify-center text-xs font-medium text-white flex-shrink-0">
50+
AI
51+
</div>
52+
) : (
53+
<div className="w-6 h-6 rounded-lg bg-gray-700 flex items-center justify-center text-xs font-medium text-white flex-shrink-0">
54+
Y
55+
</div>
56+
)}
57+
<div className="flex-1 min-w-0">
58+
<ReactMarkdown
59+
className="prose dark:prose-invert max-w-none prose-sm"
60+
rehypePlugins={[
61+
rehypeRaw,
62+
rehypeSanitize,
63+
rehypeHighlight,
64+
remarkGfm,
65+
]}
66+
>
67+
{content}
68+
</ReactMarkdown>
69+
</div>
70+
</div>
71+
)}
72+
{parts
73+
.filter((part) => part.type === 'tool-invocation')
74+
.filter(
75+
(part) => part.toolInvocation.toolName === 'recommendGuitar',
76+
)
77+
.map((toolCall) => (
78+
<div
79+
key={toolCall.toolInvocation.toolName}
80+
className="max-w-[80%] mx-auto"
81+
>
82+
<GuitarRecommendation id={toolCall.toolInvocation.args.id} />
83+
</div>
84+
))}
85+
</div>
86+
))}
87+
</div>
88+
)
89+
}
90+
91+
export default function AIAssistant() {
92+
const isOpen = useStore(showAIAssistant)
93+
const { messages, input, handleInputChange, handleSubmit } = useChat({
94+
initialMessages: [],
95+
fetch: (_url, options) => {
96+
const { messages } = JSON.parse(options!.body! as string)
97+
return genAIResponse({
98+
data: {
99+
messages,
100+
},
101+
})
102+
},
103+
onToolCall: (call) => {
104+
if (call.toolCall.toolName === 'recommendGuitar') {
105+
return 'Handled by the UI'
106+
}
107+
},
108+
})
109+
110+
return (
111+
<div className="relative z-50">
112+
<button
113+
onClick={() => showAIAssistant.setState((state) => !state)}
114+
className="flex items-center gap-2 px-3 py-1 rounded-lg bg-gradient-to-r from-orange-500 to-red-600 text-white hover:opacity-90 transition-opacity"
115+
>
116+
<div className="w-5 h-5 rounded-lg bg-white/20 flex items-center justify-center text-xs font-medium">
117+
AI
118+
</div>
119+
AI Assistant
120+
</button>
121+
122+
{isOpen && (
123+
<div className="absolute top-full right-0 mt-2 w-[700px] h-[600px] bg-gray-900 rounded-lg shadow-xl border border-orange-500/20 flex flex-col">
124+
<div className="flex items-center justify-between p-3 border-b border-orange-500/20">
125+
<h3 className="font-semibold text-white">AI Assistant</h3>
126+
<button
127+
onClick={() => showAIAssistant.setState((state) => !state)}
128+
className="text-gray-400 hover:text-white transition-colors"
129+
>
130+
<X className="w-4 h-4" />
131+
</button>
132+
</div>
133+
134+
<Messages messages={messages} />
135+
136+
<div className="p-3 border-t border-orange-500/20">
137+
<form onSubmit={handleSubmit}>
138+
<div className="relative">
139+
<textarea
140+
value={input}
141+
onChange={handleInputChange}
142+
placeholder="Type your message..."
143+
className="w-full rounded-lg border border-orange-500/20 bg-gray-800/50 pl-3 pr-10 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-orange-500/50 focus:border-transparent resize-none overflow-hidden"
144+
rows={1}
145+
style={{ minHeight: '36px', maxHeight: '120px' }}
146+
onInput={(e) => {
147+
const target = e.target as HTMLTextAreaElement
148+
target.style.height = 'auto'
149+
target.style.height =
150+
Math.min(target.scrollHeight, 120) + 'px'
151+
}}
152+
onKeyDown={(e) => {
153+
if (e.key === 'Enter' && !e.shiftKey) {
154+
e.preventDefault()
155+
handleSubmit(e)
156+
}
157+
}}
158+
/>
159+
<button
160+
type="submit"
161+
disabled={!input.trim()}
162+
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 text-orange-500 hover:text-orange-400 disabled:text-gray-500 transition-colors focus:outline-none"
163+
>
164+
<Send className="w-4 h-4" />
165+
</button>
166+
</div>
167+
</form>
168+
</div>
169+
</div>
170+
)}
171+
</div>
172+
)
173+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { useNavigate } from '@tanstack/react-router'
2+
3+
import { showAIAssistant } from '../store/example-assistant'
4+
5+
import guitars from '../data/example-guitars'
6+
7+
export default function GuitarRecommendation({ id }: { id: string }) {
8+
const navigate = useNavigate()
9+
const guitar = guitars.find((guitar) => guitar.id === +id)
10+
if (!guitar) {
11+
return null
12+
}
13+
return (
14+
<div className="my-4 rounded-lg overflow-hidden border border-orange-500/20 bg-gray-800/50">
15+
<div className="aspect-[4/3] relative overflow-hidden">
16+
<img
17+
src={guitar.image}
18+
alt={guitar.name}
19+
className="w-full h-full object-cover"
20+
/>
21+
</div>
22+
<div className="p-4">
23+
<h3 className="text-lg font-semibold text-white mb-2">{guitar.name}</h3>
24+
<p className="text-sm text-gray-300 mb-3 line-clamp-2">
25+
{guitar.shortDescription}
26+
</p>
27+
<div className="flex items-center justify-between">
28+
<div className="text-lg font-bold text-emerald-400">
29+
${guitar.price}
30+
</div>
31+
<button
32+
onClick={() => {
33+
navigate({
34+
to: '/example/guitars/$guitarId',
35+
params: { guitarId: guitar.id.toString() },
36+
})
37+
showAIAssistant.setState(() => false)
38+
}}
39+
className="bg-gradient-to-r from-orange-500 to-red-600 text-white px-4 py-1.5 rounded-lg text-sm hover:opacity-90 transition-opacity"
40+
>
41+
View Details
42+
</button>
43+
</div>
44+
</div>
45+
</div>
46+
)
47+
}

0 commit comments

Comments
 (0)