Skip to content
This repository was archived by the owner on Nov 23, 2025. It is now read-only.
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
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@stomp/stompjs": "^7.2.1",
"axios": "^1.12.2",
"js-cookie": "^3.0.5",
"lucide-react": "^0.553.0",
"next": "^16.0.1",
"react": "19.1.0",
"react-dom": "19.1.0",
Expand Down
119 changes: 83 additions & 36 deletions src/app/components/chatbot/AIChatWidget.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
// components/AIChatWidget.tsx
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Sparkles, Bolt } from 'lucide-react';
import Cookies from 'js-cookie';

// --- TypeScript Interface Definitions ---
// --- Theme Simulation & Constants ---
// NOTE: These theme classes are defined here for self-containment.
const theme = {
'theme-text-primary': 'text-gray-900 dark:text-gray-100',
'theme-text-muted': 'text-gray-500 dark:text-gray-400',
'theme-bg-primary': 'bg-gray-50 dark:bg-gray-800',
'theme-button-primary': 'bg-indigo-600 hover:bg-indigo-700 text-white',
'theme-button-secondary': 'bg-gray-200 dark:bg-gray-700',
'theme-border': 'border-gray-300 dark:border-gray-700',
'theme-input': 'bg-white dark:bg-gray-600 border theme-border p-2 rounded-lg focus:ring-indigo-500 focus:border-indigo-500',
'automotive-card': 'bg-white dark:bg-gray-800'
};

// --- TypeScript Interface Definitions (CRITICAL for TSX) ---
interface Message {
text: string;
sender: 'user' | 'ai' | 'system';
Expand All @@ -11,12 +24,19 @@ interface Message {
interface ChatResponse {
reply: string;
session_id: string;
tool_executed?: string | null; // Added tool_executed matching Python model
}

const API_ENDPOINT = '/api/v1/ai/chat'; // This will be routed by your API Gateway
// NOTE: Using localhost:8091 to test against the locally running FastAPI service
const API_ENDPOINT = 'http://localhost:8091/api/v1/ai/chat';

// --- MOCK AUTH HOOK (REMOVED) ---
// We now use js-cookie directly.

const AIChatWidget: React.FC = () => {
// 1. State Management
const [userToken, setUserToken] = useState<string | null>(null); // For UI control

const [conversationHistory, setConversationHistory] = useState<Message[]>([
{ text: "Hello! I'm TechTorque Assistant. How can I help you with your services or appointments?", sender: 'ai' }
]);
Expand All @@ -25,18 +45,24 @@ const AIChatWidget: React.FC = () => {
const [isLoading, setIsLoading] = useState<boolean>(false);

const messagesEndRef = useRef<HTMLDivElement>(null);

// Check for cookie on mount to set initial UI state
useEffect(() => {
const token = Cookies.get('tt_access_token');
setUserToken(token || null);
}, []);

// Auto-scroll to the latest message
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [conversationHistory]);

// 3. The Core Logic (Memoized for performance)
// 2. The Core Logic
const sendMessage = useCallback(async (message: string) => {
// Get token directly from cookies
const userToken = Cookies.get('tt_access_token');
// Get token directly from cookies to ensure it's the latest
const currentToken = Cookies.get('tt_access_token');

if (!message.trim() || isLoading || !userToken) return;
if (!message.trim() || isLoading || !currentToken) return;

// Add user message to history
const userMessage: Message = { text: message, sender: 'user' };
Expand All @@ -45,39 +71,45 @@ const AIChatWidget: React.FC = () => {
setIsLoading(true);

try {
// 4. API Payload (Must match the Python ChatRequest model!)
// 3. API Payload (Must match the Python ChatRequest model!)
const payload = {
query: message,
session_id: sessionId,
token: userToken, // Passed in body for Agent_Bot context retrieval
token: currentToken, // Sent in body for Agent_Bot context
};

const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${userToken}`, // Passed in header for Gateway validation
'Authorization': `Bearer ${currentToken}`, // Sent in header for Gateway validation
},
body: JSON.stringify(payload),
});

if (!response.ok) {
// If 401/403, log out or show an error
// Handle non-200 responses (e.g., 401, 500)
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to get a reply.');
throw new Error(errorData.detail || `HTTP Error ${response.status}`);
}

const data: ChatResponse = await response.json();

// 5. Update state with AI response and new session ID
const aiResponse: Message = { text: data.reply, sender: 'ai' };
// 4. Update state with AI response and new session ID
let replyText = data.reply;
if (data.tool_executed) {
// Indicate tool execution for debugging/clarity
replyText = `⚙️ (Tool used: ${data.tool_executed}) ${replyText}`;
}

const aiResponse: Message = { text: replyText, sender: 'ai' };
setConversationHistory(prev => [...prev, aiResponse]);
setSessionId(data.session_id); // CRITICAL: Save the session ID for the next turn
setSessionId(data.session_id); // CRITICAL: Save the session ID

} catch (error: unknown) {
console.error("Chat Error:", error);
const errorMessage: Message = {
text: (error instanceof Error && error.message.includes('401'))
text: (error instanceof Error && (error.message.includes('401') || error.message.includes('Unauthorized')))
? "Your session has expired. Please log in again."
: "Sorry, I'm having trouble with the services. Try again later.",
sender: 'system'
Expand All @@ -88,69 +120,84 @@ const AIChatWidget: React.FC = () => {
}
}, [isLoading, sessionId]); // Dependencies for useCallback

// 6. Handler for form submission
// 5. Handler for form submission
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
sendMessage(inputMessage);
};

// --- RENDER FUNCTION (JSX) ---
return (
<div className="flex flex-col h-full bg-white rounded-lg shadow-xl">
<div className={`flex flex-col h-full ${theme['automotive-card']} shadow-lg border ${theme['theme-border']} rounded-xl max-w-lg mx-auto md:max-w-xl`}>

{/* Header */}
<div className="p-4 border-b bg-gray-50">
<h3 className="text-lg font-semibold text-indigo-700">TechTorque AI Assistant</h3>
<div className={`p-4 ${theme['theme-bg-primary']} border-b ${theme['theme-border']} rounded-t-xl flex items-center`}>
<div className="w-8 h-8 rounded-full bg-gradient-to-r from-indigo-500 to-cyan-400 flex items-center justify-center shadow-md mr-3">
<Bolt className="w-4 h-4 text-white" />
</div>
<h3 className={`text-lg font-bold ${theme['theme-text-primary']}`}>
TechTorque AI Assistant
</h3>
</div>

{/* Message Display Area */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
<div className={`flex-1 overflow-y-auto p-4 space-y-3 ${theme['theme-bg-primary']}`}>
{conversationHistory.map((msg, index) => (
<div
key={index}
className={`flex ${msg.sender === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div className={`max-w-xs px-4 py-2 rounded-xl text-sm ${
<div className={`max-w-[80%] md:max-w-[70%] px-4 py-2 rounded-xl text-sm break-words shadow-sm ${
msg.sender === 'user'
? 'bg-indigo-500 text-white rounded-br-none'
: 'bg-gray-200 text-gray-800 rounded-tl-none'
? `${theme['theme-button-primary']} rounded-br-none`
: `${theme['theme-button-secondary']} ${theme['theme-text-primary']} rounded-tl-none`
}`}>
{msg.sender === 'ai' && <Sparkles className="w-4 h-4 inline mr-1" />}
{msg.text}
</div>
</div>
))}

{/* Typing Indicator */}
{isLoading && (
<div className="flex justify-start">
<div className="max-w-xs px-4 py-2 rounded-xl text-sm bg-gray-200 text-gray-800 rounded-tl-none animate-pulse">
Thinking...
<div className={`max-w-xs px-4 py-2 rounded-xl text-sm ${theme['theme-button-secondary']} ${theme['theme-text-muted']} rounded-tl-none animate-pulse`}>
<Sparkles className="w-4 h-4 inline mr-1" /> Thinking...
</div>
</div>
)}
<div ref={messagesEndRef} /> {/* Auto-scroll reference */}
<div ref={messagesEndRef} />
</div>

{/* Input Form */}
<form onSubmit={handleSubmit} className="p-4 border-t flex space-x-2">
<form onSubmit={handleSubmit} className={`p-4 border-t ${theme['theme-border']} flex space-x-2 ${theme['theme-bg-primary']} rounded-b-xl`}>
<input
type="text"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
placeholder="Ask about appointments, status, or services..."
className="flex-1 p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
disabled={isLoading}
placeholder={!userToken ? "Please sign in to chat..." : "Ask about status, scheduling, or services..."}
className={`${theme['theme-input']} flex-1 w-full`}
disabled={isLoading || !userToken}
/>
<button
type="submit"
className={`px-4 py-2 rounded-lg font-semibold transition duration-150 ${
isLoading || !inputMessage.trim()
? 'bg-indigo-300 text-white cursor-not-allowed'
: 'bg-indigo-600 text-white hover:bg-indigo-700'
className={`px-4 py-2 rounded-lg font-semibold transition duration-150 ${theme['theme-button-primary']} ${
isLoading || !inputMessage.trim() || !userToken
? 'opacity-60 cursor-not-allowed'
: ''
}`}
disabled={isLoading || !inputMessage.trim()}
disabled={isLoading || !inputMessage.trim() || !userToken}
>
Send
</button>
</form>

{/* Login Warning Message */}
{!userToken && (
<div className={`p-2 text-center text-xs text-red-500 ${theme['theme-bg-primary']} border-t ${theme['theme-border']}`}>
<p className='text-xs'>Please log in to start using the AI assistant.</p>
</div>
)}
</div>
);
};
Expand Down