diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..51ccc79 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,29 @@ +name: Build Check + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build project + run: npm run build \ No newline at end of file diff --git a/app/about/page.tsx b/app/about/page.tsx index b2d1fa9..f517b46 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -4,19 +4,19 @@ import { ExternalLink } from "lucide-react" export default function AboutPage() { return ( -
+
{/* Hero Section */} -
+
Pakistan Parliament
-
-

+
+

Meet Numainda

@@ -69,11 +69,11 @@ export default function AboutPage() { Our Story in the Media - + Code for Pakistan Logo

Say Hello to My New Friend

@@ -89,7 +89,7 @@ export default function AboutPage() { className="flex items-center gap-2" > Read on Code for Pakistan - +
diff --git a/app/admin/upload/page.tsx b/app/admin/upload/page.tsx index b194062..caac2e2 100644 --- a/app/admin/upload/page.tsx +++ b/app/admin/upload/page.tsx @@ -19,6 +19,7 @@ export default function UploadPage() { const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const [message, setMessage] = useState(''); + const [documentType, setDocumentType] = useState(''); const checkPassword = async (inputPassword: string) => { try { @@ -50,7 +51,7 @@ export default function UploadPage() { if (!isAuthorized) { return (
-

Admin Access

+

Admin Access

@@ -82,7 +83,7 @@ export default function UploadPage() { return (
-

Upload Document

+

Upload Document

@@ -92,7 +93,11 @@ export default function UploadPage() {
- setDocumentType(value)} + > @@ -104,6 +109,18 @@ export default function UploadPage() {
+ {documentType === 'parliamentary_bulletin' && ( +
+ + +
+ )} +
diff --git a/app/api/auth/userinfo/route.ts b/app/api/auth/userinfo/route.ts new file mode 100644 index 0000000..8f810e8 --- /dev/null +++ b/app/api/auth/userinfo/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server" + +export async function GET(request: Request) { + const authHeader = request.headers.get('Authorization') + + if (!authHeader) { + return NextResponse.json({ error: 'No authorization header' }, { status: 401 }) + } + + try { + const response = await fetch(`${process.env.NEXT_PUBLIC_PEHCHAN_URL}/api/auth/userinfo`, { + headers: { + 'Authorization': authHeader + } + }) + + if (!response.ok) { + throw new Error('Failed to fetch user info') + } + + const data = await response.json() + console.log('Pehchan userinfo response:', data) + return NextResponse.json(data) + } catch (error) { + console.error('User info error:', error) + return NextResponse.json( + { error: 'Failed to fetch user info' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/app/api/chat/threads/[id]/route.ts b/app/api/chat/threads/[id]/route.ts new file mode 100644 index 0000000..5bd8314 --- /dev/null +++ b/app/api/chat/threads/[id]/route.ts @@ -0,0 +1,95 @@ +import { db } from '@/lib/db' +import { chatThreads } from '@/lib/db/schema/chat-threads' +import { eq, and } from 'drizzle-orm' +import { NextResponse } from 'next/server' + +export async function GET( + request: Request, + { params }: { params: { id: string } } +) { + const { searchParams } = new URL(request.url) + const pehchanId = searchParams.get('pehchan_id') + + if (!pehchanId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const [thread] = await db + .select() + .from(chatThreads) + .where( + and( + eq(chatThreads.id, parseInt(params.id)), + eq(chatThreads.pehchanId, pehchanId) + ) + ) + + return NextResponse.json(thread) + } catch (error) { + return NextResponse.json({ error: 'Failed to fetch thread' }, { status: 500 }) + } +} + +export async function PATCH( + request: Request, + { params }: { params: { id: string } } +) { + const { messages, title, pehchanId } = await request.json() + + if (!pehchanId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const [thread] = await db + .update(chatThreads) + .set({ + messages, + title, + updatedAt: new Date() + }) + .where( + and( + eq(chatThreads.id, parseInt(params.id)), + eq(chatThreads.pehchanId, pehchanId) + ) + ) + .returning() + + return NextResponse.json(thread) + } catch (error) { + return NextResponse.json({ error: 'Failed to update thread' }, { status: 500 }) + } +} + +export async function DELETE( + request: Request, + { params }: { params: { id: string } } +) { + const { pehchanId } = await request.json() + + if (!pehchanId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const [deletedThread] = await db + .delete(chatThreads) + .where( + and( + eq(chatThreads.id, parseInt(params.id)), + eq(chatThreads.pehchanId, pehchanId) + ) + ) + .returning() + + return NextResponse.json(deletedThread) + } catch (error) { + console.error('Error deleting thread:', error) + return NextResponse.json( + { error: 'Failed to delete thread' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/app/api/chat/threads/route.ts b/app/api/chat/threads/route.ts new file mode 100644 index 0000000..63ecaa0 --- /dev/null +++ b/app/api/chat/threads/route.ts @@ -0,0 +1,51 @@ +import { db } from '@/lib/db' +import { chatThreads } from '@/lib/db/schema/chat-threads' +import { eq } from 'drizzle-orm' +import { NextResponse } from 'next/server' + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url) + const pehchanId = searchParams.get('pehchan_id') + + if (!pehchanId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const threads = await db + .select() + .from(chatThreads) + .where(eq(chatThreads.pehchanId, pehchanId)) + .orderBy(chatThreads.updatedAt) + + return NextResponse.json(threads) + } catch (error) { + return NextResponse.json({ error: 'Failed to fetch threads' }, { status: 500 }) + } +} + +export async function POST(request: Request) { + const { pehchanId, title = 'New Chat', messages = [] } = await request.json() + console.log('Creating thread:', { pehchanId, title, messages }) + + if (!pehchanId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const [thread] = await db + .insert(chatThreads) + .values({ + pehchanId, + title, + messages + }) + .returning() + + console.log('Created thread:', thread) + return NextResponse.json(thread) + } catch (error) { + console.error('Failed to create thread:', error) + return NextResponse.json({ error: 'Failed to create thread' }, { status: 500 }) + } +} \ No newline at end of file diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx new file mode 100644 index 0000000..aea26da --- /dev/null +++ b/app/auth/callback/page.tsx @@ -0,0 +1,101 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { useToast } from '@/hooks/use-toast' + +export default function AuthCallback() { + const router = useRouter() + const { toast } = useToast() + const [isProcessing, setIsProcessing] = useState(true) + + useEffect(() => { + const handleCallback = async () => { + try { + const params = new URLSearchParams(window.location.search) + console.log('Callback URL params:', Object.fromEntries(params.entries())) + + // Check for direct token response first (implicit flow) + const accessToken = params.get('access_token') + const idToken = params.get('id_token') + const error = params.get('error') + + console.log('Received params:', { + hasAccessToken: !!accessToken, + hasIdToken: !!idToken, + hasError: !!error + }) + + if (error) { + throw new Error(error) + } + + // Handle implicit flow (direct token response) + if (accessToken) { + localStorage.setItem('access_token', accessToken) + if (idToken) localStorage.setItem('id_token', idToken) + + // Dispatch custom event for same-tab updates + window.dispatchEvent(new Event('localStorageChange')) + + // Use our proxy endpoint instead + const userResponse = await fetch('/api/auth/userinfo', { + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }) + + if (!userResponse.ok) { + throw new Error('Failed to fetch user info') + } + + const userInfo = await userResponse.json() + console.log('User info:', userInfo) + localStorage.setItem('user_info', JSON.stringify(userInfo)) + localStorage.setItem('pehchan_id', userInfo.profile.cnic) + + toast({ + title: "Login successful", + description: "Welcome to Numainda" + }) + + router.push('/chat') + return + } + + // If we get here and don't have tokens, something went wrong + throw new Error( + `Authentication failed. ` + + `Received parameters: ${JSON.stringify(Object.fromEntries(params.entries()))}` + ) + + } catch (error) { + console.error('Auth callback error:', error) + toast({ + variant: "destructive", + title: "Authentication Failed", + description: error instanceof Error ? error.message : "An error occurred during login" + }) + router.push('/chat') + } finally { + sessionStorage.removeItem('auth_state') + setIsProcessing(false) + } + } + + handleCallback() + }, [router, toast]) + + if (isProcessing) { + return ( +
+
+
+

Processing your login...

+
+
+ ) + } + + return null +} \ No newline at end of file diff --git a/app/chat/page.tsx b/app/chat/page.tsx index 2aee020..bb0813a 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -10,11 +10,14 @@ import { RefreshCcw, SendIcon, User, + LogOut, + Loader2, } from "lucide-react" import Markdown from "react-markdown" import remarkGfm from "remark-gfm" +import { useRouter, useSearchParams } from "next/navigation" +import { useToast } from "@/hooks/use-toast" -import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { ChatBubble, @@ -24,6 +27,7 @@ import { } from "@/components/ui/chat/chat-bubble" import { ChatInput } from "@/components/ui/chat/chat-input" import { MessageThreadsSidebar } from "@/app/components/message-threads-sidebar" +import { PehchanLoginButton } from "@/components/pehchan-button" const ChatAiIcons = [ { icon: CopyIcon, label: "Copy" }, @@ -33,6 +37,75 @@ const ChatAiIcons = [ export default function ChatPage() { const [isGenerating, setIsGenerating] = useState(false) const [isSidebarOpen, setIsSidebarOpen] = useState(false) + const [isAuthenticated, setIsAuthenticated] = useState(false) + const router = useRouter() + const { toast } = useToast() + const searchParams = useSearchParams() + const [threadId, setThreadId] = useState(null) + const [isClient, setIsClient] = useState(false) + + useEffect(() => { + setIsClient(true) + }, []) + + useEffect(() => { + const accessToken = localStorage.getItem('access_token') + console.log('Auth check - access_token:', accessToken) + setIsAuthenticated(!!accessToken) + }, []) + + useEffect(() => { + const loadOrCreateThread = async () => { + console.log('loadOrCreateThread called, isAuthenticated:', isAuthenticated) + if (!isAuthenticated) return + + const pehchanId = localStorage.getItem('pehchan_id') + console.log('Pehchan ID:', pehchanId) + if (!pehchanId) return + + const threadIdParam = searchParams.get('thread') + if (threadIdParam) { + console.log('Loading thread:', threadIdParam) + const response = await fetch(`/api/chat/threads/${threadIdParam}?pehchan_id=${pehchanId}`) + const thread = await response.json() + console.log('Loaded thread:', thread) + + if (thread) { + setThreadId(thread.id) + setMessages(thread.messages) + } + } else { + console.log('Creating new thread') + const response = await fetch('/api/chat/threads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + pehchanId, + title: 'New Chat' + }) + }) + const thread = await response.json() + console.log('Created thread:', thread) + + if (thread) { + setThreadId(thread.id) + router.push(`/chat?thread=${thread.id}`) + } + } + } + + loadOrCreateThread() + }, [isAuthenticated, searchParams]) + + const handleLogout = () => { + localStorage.clear() + window.dispatchEvent(new Event('localStorageChange')) + toast({ + title: "Logged out", + description: "You have been successfully logged out" + }) + router.refresh() + } const { messages, @@ -41,6 +114,7 @@ export default function ChatPage() { handleSubmit, isLoading, reload, + setMessages, } = useChat({ api: "/api/chat", initialMessages: [ @@ -52,7 +126,23 @@ export default function ChatPage() { }, ], onResponse: (response) => { - if (response) setIsGenerating(false) + if (response) { + setIsGenerating(false) + // Scroll to bottom when response starts streaming + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) + + if (threadId) { + fetch(`/api/chat/threads/${threadId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messages, + title: messages[1]?.content.slice(0, 100) || 'New Chat', + pehchanId: localStorage.getItem('pehchan_id') + }) + }) + } + } }, onError: (error) => { if (error) setIsGenerating(false) @@ -65,63 +155,114 @@ export default function ChatPage() { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) }, [messages]) + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text) + toast({ + title: "Copied to clipboard", + description: "Message content has been copied to your clipboard", + }) + } catch (err) { + toast({ + title: "Failed to copy", + description: "Could not copy the message to clipboard", + variant: "destructive", + }) + } + } + return ( -
+
setIsSidebarOpen(false)} /> -
+
{/* Header */} -
- - - Numainda Chat +
+
+ + + Numainda Chat +
+ +
- {/* Messages */} -
- {messages.map((message) => ( - - - ) : ( - - ) - } - /> - - - {message.content} - - - - ))} -
+ {/* Messages container */} +
+
+ {isClient && messages.map((message) => ( + + + ) : ( + + ) + } + /> + + + {message.content} + + + {message.role === "assistant" && isClient && ( + copyToClipboard(message.content)} + > + + Copy message + + } /> + )} + + ))} + {isGenerating && isClient && ( + + } + /> + +
+ + Numainda is thinking... +
+
+
+ )} +
+
- {/* Input */} -
-
+ {/* Input - now will stay fixed at bottom */} +
+
{ @@ -135,7 +276,7 @@ export default function ChatPage() { value={input} onChange={handleInputChange} placeholder="Message Numainda..." - className="w-full rounded-lg border px-3 py-2 text-base bg-slate-500/10" + className="w-full rounded-lg border bg-slate-500/10 px-3 py-2 text-base" style={{ fontSize: "16px" }} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { diff --git a/app/components/message-threads-sidebar.tsx b/app/components/message-threads-sidebar.tsx index 4847735..1c283af 100644 --- a/app/components/message-threads-sidebar.tsx +++ b/app/components/message-threads-sidebar.tsx @@ -1,59 +1,182 @@ import { Button } from "@/components/ui/button"; -import { MessageCircle, Plus, X } from 'lucide-react'; +import { MessageCircle, Plus, X, Trash2 } from 'lucide-react'; import { cn } from "@/lib/utils"; +import { useEffect, useState } from "react"; +import { PehchanLoginButton } from "@/components/pehchan-button"; +import { useRouter } from 'next/navigation' interface MessageThreadsSidebarProps { isOpen: boolean; onClose: () => void; } +interface ChatThread { + id: string + title: string + created_at: string + messages: any[] +} + export function MessageThreadsSidebar({ isOpen, onClose }: MessageThreadsSidebarProps) { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [threads, setThreads] = useState([]) + const router = useRouter() + + useEffect(() => { + // Check initial auth state + const checkAuth = () => { + const accessToken = localStorage.getItem('access_token'); + console.log('Sidebar auth check - access_token:', accessToken) + setIsAuthenticated(!!accessToken); + }; + + // Check auth on mount + checkAuth(); + + // Listen for storage changes + const handleStorageChange = (e: StorageEvent) => { + if (e.key === 'access_token') { + checkAuth(); + } + }; + + window.addEventListener('storage', handleStorageChange); + + // Custom event for same-tab updates + const handleCustomStorageChange = () => checkAuth(); + window.addEventListener('localStorageChange', handleCustomStorageChange); + + return () => { + window.removeEventListener('storage', handleStorageChange); + window.removeEventListener('localStorageChange', handleCustomStorageChange); + }; + }, []); + + useEffect(() => { + const loadThreads = async () => { + console.log('loadThreads called, isAuthenticated:', isAuthenticated) + const pehchanId = localStorage.getItem('pehchan_id') + console.log('Sidebar - Pehchan ID:', pehchanId) + if (!pehchanId) return + + const response = await fetch(`/api/chat/threads?pehchan_id=${pehchanId}`) + const threads = await response.json() + setThreads(threads) + } + + if (isAuthenticated) { + loadThreads() + // Set up polling for updates + const interval = setInterval(loadThreads, 5000) + return () => clearInterval(interval) + } + }, [isAuthenticated]) + + const handleDeleteThread = async (threadId: string, e: React.MouseEvent) => { + e.stopPropagation(); // Prevent triggering the thread selection + + const pehchanId = localStorage.getItem('pehchan_id'); + if (!pehchanId) return; + + try { + const response = await fetch(`/api/chat/threads/${threadId}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pehchanId }) + }); + + if (response.ok) { + // Remove thread from local state + setThreads(threads.filter(thread => thread.id !== threadId)); + // If we're currently viewing this thread, redirect to new chat + const currentThreadId = new URLSearchParams(window.location.search).get('thread'); + if (currentThreadId === threadId) { + router.push('/chat'); + } + } + } catch (error) { + console.error('Failed to delete thread:', error); + } + }; + return ( <> {isOpen && (
)} diff --git a/app/layout.tsx b/app/layout.tsx index bb8368c..234dfc3 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,6 +8,7 @@ import { SiteHeader } from "@/components/site-header" import { TailwindIndicator } from "@/components/tailwind-indicator" import { ThemeProvider } from "@/components/theme-provider" import { Footer } from "@/components/footer" +import { Toaster } from "@/components/ui/toaster" export const metadata: Metadata = { title: { @@ -46,6 +47,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
{children}
+
diff --git a/app/page.tsx b/app/page.tsx index e7e650e..7920daf 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -7,28 +7,28 @@ import { Book, LucideBook, LandmarkIcon, MessageSquare, Info, ScaleIcon } from ' export default function IndexPage() { return (
-
+
Supreme Court of Pakistan
-
-

+
+

Welcome to Numainda
Your Guide to Pakistan's Constitution and Laws

-

+

Explore Pakistan's rich legal and parliamentary heritage with our AI-powered chatbot. Gain insights into the constitution, election laws, and parliamentary bulletins.

-
+
- + Start Chatting - + Learn More
@@ -57,7 +57,7 @@ export default function IndexPage() { - + Explore the Constitution @@ -68,7 +68,7 @@ export default function IndexPage() { - + Election Laws Demystified @@ -79,7 +79,7 @@ export default function IndexPage() { - + Parliamentary Insights @@ -90,8 +90,8 @@ export default function IndexPage() {
-

- +

+ Why We Built Numainda

diff --git a/app/proceedings/[id]/page.tsx b/app/proceedings/[id]/page.tsx index 6c32183..5029256 100644 --- a/app/proceedings/[id]/page.tsx +++ b/app/proceedings/[id]/page.tsx @@ -19,17 +19,17 @@ export default async function ProceedingPage({ return (

-
- +
+
-

{proceeding.title}

+

{proceeding.title}

{format(new Date(proceeding.date), 'MMMM d, yyyy')}

-

Summary

+

Summary

{proceeding.summary}
diff --git a/app/proceedings/page.tsx b/app/proceedings/page.tsx index 33ba0fa..3cec14b 100644 --- a/app/proceedings/page.tsx +++ b/app/proceedings/page.tsx @@ -7,15 +7,15 @@ export default async function ProceedingsPage() { return (
-

Parliamentary Proceedings

+

Parliamentary Proceedings

{proceedings.map((proceeding) => ( -
+

{proceeding.title}

{format(new Date(proceeding.date), 'MMMM d, yyyy')}
diff --git a/components/__tests__/pehchan-button.test.tsx b/components/__tests__/pehchan-button.test.tsx new file mode 100644 index 0000000..3724b6f --- /dev/null +++ b/components/__tests__/pehchan-button.test.tsx @@ -0,0 +1,104 @@ +import '@testing-library/jest-dom' +import { afterEach, jest } from '@jest/globals' +import { render, screen, fireEvent } from '@testing-library/react' +import { PehchanLoginButton } from '../pehchan-button' + +// Mock the Button component +jest.mock('@/components/ui/button', () => ({ + Button: ({ children, onClick, className }: any) => ( + + ) +})) + +// Mock the Icons component +jest.mock('@/components/icons', () => ({ + Icons: { + whiteLogo: () =>
Logo
+ } +})) + +describe('PehchanLoginButton', () => { + const originalEnv = process.env + const mockUUID = '123e4567-e89b-12d3-a456-426614174000' + + beforeEach(() => { + // Mock environment variables + process.env = { + ...originalEnv, + NEXT_PUBLIC_PEHCHAN_URL: 'https://pehchan.test', + NEXT_PUBLIC_CLIENT_ID: 'test-client-id' + } + + // Mock window.location + Object.defineProperty(window, 'location', { + configurable: true, + value: { + origin: 'https://numainda.test', + href: '', + assign: jest.fn() + } + }) + + // Mock crypto.randomUUID + jest.spyOn(crypto, 'randomUUID').mockImplementation(() => mockUUID) + }) + + afterEach(() => { + process.env = originalEnv + jest.clearAllMocks() + sessionStorage.clear() + }) + + it('renders correctly', () => { + render() + expect(screen.getByText('Login with Pehchan')).toBeInTheDocument() + expect(screen.getByTestId('mock-white-logo')).toBeInTheDocument() + }) + + it('constructs correct login URL with all required parameters when clicked', () => { + render() + fireEvent.click(screen.getByText('Login with Pehchan')) + + const expectedUrl = new URL('https://pehchan.test/login') + expectedUrl.searchParams.set('service_name', 'Numainda') + expectedUrl.searchParams.set('client_id', 'test-client-id') + expectedUrl.searchParams.set('redirect_uri', 'https://numainda.test/auth/callback') + expectedUrl.searchParams.set('response_type', 'code') + expectedUrl.searchParams.set('scope', 'openid profile email') + expectedUrl.searchParams.set('state', '123e4567-e89b-12d3-a456-426614174000') + + expect(sessionStorage.getItem('auth_state')).toBe('123e4567-e89b-12d3-a456-426614174000') + expect(window.location.href).toBe(expectedUrl.toString()) + }) + + it('handles missing environment variables gracefully', () => { + delete process.env.NEXT_PUBLIC_PEHCHAN_URL + delete process.env.NEXT_PUBLIC_CLIENT_ID + + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + render() + fireEvent.click(screen.getByText('Login with Pehchan')) + + expect(consoleSpy).toHaveBeenCalled() + consoleSpy.mockRestore() + }) + + it('applies correct styling to the button', () => { + render() + + const button = screen.getByRole('button') + expect(button).toHaveClass( + 'hover:bg-green-700', + 'gap-2', + 'text-white', + 'py-2', + 'px-4', + 'rounded-md', + 'flex', + 'items-center', + 'justify-center', + 'transition-colors' + ) + }) +}) \ No newline at end of file diff --git a/components/chat/chat-input.tsx b/components/chat/chat-input.tsx index b5837b2..2563f41 100644 --- a/components/chat/chat-input.tsx +++ b/components/chat/chat-input.tsx @@ -53,7 +53,7 @@ export function ChatInput({ input, setInput, onSubmit, isLoading }: ChatInputPro size="icon" disabled={isLoading || input === ""} > - + Send message
diff --git a/components/chat/message.tsx b/components/chat/message.tsx index 5844779..e9a46f9 100644 --- a/components/chat/message.tsx +++ b/components/chat/message.tsx @@ -13,29 +13,29 @@ export function ChatMessage({ role, content, isLoading }: ChatMessageProps) {
{role === "user" ? ( - + ) : ( - + )}
{isLoading ? (
-
-
-
+
+
+
) : ( {content} diff --git a/components/footer.tsx b/components/footer.tsx index 82e1ada..8b3d5e6 100644 --- a/components/footer.tsx +++ b/components/footer.tsx @@ -5,7 +5,7 @@ import { cn } from "@/lib/utils" export function Footer({ className }: { className?: string }) { return (