chat fab
This commit is contained in:
68
src/app/chat/_components/AssistantMessage.tsx
Normal file
68
src/app/chat/_components/AssistantMessage.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import Markdown from "react-markdown";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export const AssistantMessage = (props: { message: UIMessage }) => {
|
||||
let message = props.message;
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className='flex justify-start'
|
||||
>
|
||||
<div
|
||||
className=
|
||||
'max-w-[80%] px-4 py-2 text-sm space-y-2 bg-muted'
|
||||
>
|
||||
{message.parts.map((part, i) => {
|
||||
if (part.type === 'text') {
|
||||
return (
|
||||
<Markdown>
|
||||
{part.text}
|
||||
</Markdown>
|
||||
)
|
||||
}
|
||||
if (part.type === 'tool-scheduleMeeting') {
|
||||
const toolPart = part as unknown as {
|
||||
type: 'tool-scheduleMeeting'
|
||||
state: string
|
||||
input: unknown
|
||||
output?: { success: boolean; message?: string; htmlLink?: string; error?: string }
|
||||
}
|
||||
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
|
||||
return (
|
||||
<p key={i} className="text-xs opacity-70 italic">
|
||||
Scheduling meeting…
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (toolPart.state === 'output-available' && toolPart.output) {
|
||||
const result = toolPart.output
|
||||
return (
|
||||
<div key={i} className="text-xs mt-1 p-2 bg-background/20 rounded">
|
||||
{result.success ? (
|
||||
<span>
|
||||
✓ {result.message}{' '}
|
||||
{result.htmlLink && (
|
||||
<a
|
||||
href={result.htmlLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
View event
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>✗ {result.error}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import { DefaultChatTransport, type UIMessage } from 'ai'
|
||||
import { Button } from '~/components/ui/button'
|
||||
import { Textarea } from '~/components/ui/textarea'
|
||||
import { cn } from '~/lib/utils'
|
||||
import Markdown from 'react-markdown';
|
||||
import { AssistantMessage } from './AssistantMessage';
|
||||
import { UserMessage } from './UserMessage';
|
||||
|
||||
type DBMessage = {
|
||||
id: string
|
||||
@@ -29,7 +32,7 @@ export default function ChatInterface({ sessionId, initialMessages }: ChatInterf
|
||||
const [input, setInput] = useState('')
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { messages, sendMessage, status } = useChat({
|
||||
const { messages, sendMessage, status, error, clearError } = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: '/api/chat',
|
||||
body: { sessionId },
|
||||
@@ -38,6 +41,7 @@ export default function ChatInterface({ sessionId, initialMessages }: ChatInterf
|
||||
})
|
||||
|
||||
const isLoading = status === 'submitted' || status === 'streaming'
|
||||
const hasError = status === 'error'
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
@@ -61,67 +65,10 @@ export default function ChatInterface({ sessionId, initialMessages }: ChatInterf
|
||||
)}
|
||||
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn('flex', message.role === 'user' ? 'justify-end' : 'justify-start')}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[80%] rounded-lg px-4 py-2 text-sm space-y-2',
|
||||
message.role === 'user' ? 'bg-primary text-primary-foreground' : 'bg-muted',
|
||||
)}
|
||||
>
|
||||
{message.parts.map((part, i) => {
|
||||
if (part.type === 'text') {
|
||||
return (
|
||||
<p key={i} className="whitespace-pre-wrap">
|
||||
{part.text}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (part.type === 'tool-scheduleMeeting') {
|
||||
const toolPart = part as unknown as {
|
||||
type: 'tool-scheduleMeeting'
|
||||
state: string
|
||||
input: unknown
|
||||
output?: { success: boolean; message?: string; htmlLink?: string; error?: string }
|
||||
}
|
||||
if (toolPart.state === 'input-available' || toolPart.state === 'input-streaming') {
|
||||
return (
|
||||
<p key={i} className="text-xs opacity-70 italic">
|
||||
Scheduling meeting…
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (toolPart.state === 'output-available' && toolPart.output) {
|
||||
const result = toolPart.output
|
||||
return (
|
||||
<div key={i} className="text-xs mt-1 p-2 bg-background/20 rounded">
|
||||
{result.success ? (
|
||||
<span>
|
||||
✓ {result.message}{' '}
|
||||
{result.htmlLink && (
|
||||
<a
|
||||
href={result.htmlLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
View event
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>✗ {result.error}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
{message.role == 'assistant' && <AssistantMessage message={message}/>}
|
||||
{message.role == 'user' && <UserMessage message={message}/>}
|
||||
</>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
@@ -135,6 +82,24 @@ export default function ChatInterface({ sessionId, initialMessages }: ChatInterf
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mx-4 mb-2 flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<span className="flex-1">
|
||||
{error.message.includes('quota') || error.message.includes('429')
|
||||
? 'OpenAI quota exceeded. Please try again later.'
|
||||
: `Error: ${error.message}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearError}
|
||||
className="shrink-0 opacity-60 hover:opacity-100"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 border-t flex gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
@@ -151,7 +116,7 @@ export default function ChatInterface({ sessionId, initialMessages }: ChatInterf
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={isLoading || !input.trim()}
|
||||
disabled={isLoading || hasError || !input.trim()}
|
||||
className="self-end"
|
||||
>
|
||||
Send
|
||||
|
||||
23
src/app/chat/_components/UserMessage.tsx
Normal file
23
src/app/chat/_components/UserMessage.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { UIMessage } from "ai"
|
||||
|
||||
export const UserMessage = (props:{message: UIMessage}) => {
|
||||
let message = props.message.parts.reduce((acc, part) => {
|
||||
if (part.type == 'text') {
|
||||
return acc + part.text
|
||||
}
|
||||
return acc
|
||||
},"");
|
||||
return (
|
||||
<div
|
||||
key={props.message.id}
|
||||
className='flex justify-end'
|
||||
>
|
||||
<div
|
||||
className=
|
||||
'max-w-[80%] px-4 py-2 text-sm space-y-2 bg-primary'
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user