feat: add voice recognition, logo positioning, and UI improvements
This commit is contained in:
parent
1294756aea
commit
7e6f65bc82
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
17
index.html
Normal file
17
index.html
Normal file
@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<title>AI Chat Interface</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
1726
package-lock.json
generated
Normal file
1726
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "chatbot-ui",
|
||||
"version": "1.0.0",
|
||||
"description": "AI Chat Interface with Modern Design",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"lucide-react": "^0.294.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/0x0.png
Normal file
BIN
public/0x0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
8
src/App.jsx
Normal file
8
src/App.jsx
Normal file
@ -0,0 +1,8 @@
|
||||
import ChatInterface from './components/ChatInterface'
|
||||
|
||||
function App() {
|
||||
return <ChatInterface />
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
488
src/components/ChatInterface.css
Normal file
488
src/components/ChatInterface.css
Normal file
@ -0,0 +1,488 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: radial-gradient(
|
||||
circle at center center,
|
||||
#ffffff 0%,
|
||||
#fdf2f8 50%,
|
||||
#eef2ff 100%
|
||||
);
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 80px 20px 40px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Header Logo */
|
||||
.header-logo {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
margin-bottom: 0px;
|
||||
width: auto;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
max-width: 120px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Greeting Container */
|
||||
.greeting-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.greeting-text {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: linear-gradient(to right, #8B5CF6, #EC4899);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Main Heading */
|
||||
.main-heading {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 32px;
|
||||
font-weight: 400;
|
||||
color: #111827;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 48px 0;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Messages Container */
|
||||
.messages-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
margin-bottom: 40px;
|
||||
align-items: flex-start;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Message Wrapper */
|
||||
.message-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.message-wrapper.user {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* Message Label */
|
||||
.message-label {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: #9ca3af;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 8px;
|
||||
padding: 4px 8px;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* User Message Label (ME) */
|
||||
.message-wrapper.user .message-label {
|
||||
background-color: #4da6ff;
|
||||
color: #ffffff;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
/* AI Message Label (Chatbot) */
|
||||
.message-wrapper.ai .message-label {
|
||||
color: #9ca3af;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Message Bubble */
|
||||
.message-bubble {
|
||||
width: fit-content;
|
||||
max-width: 85%;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.message-bubble.user {
|
||||
padding: 12px 16px;
|
||||
background-color: #4da6ff;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.message-bubble.user .message-text {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.message-bubble.ai {
|
||||
padding: 16px 20px;
|
||||
max-width: 90%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Message Image Container */
|
||||
.message-image-container {
|
||||
width: 100%;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.message-image {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 400px;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.message-image:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.message-bubble.ai .message-image-container {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.message-bubble.ai .message-image-container:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Message Text */
|
||||
.message-text {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
white-space: pre-line;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.message-bubble.ai .message-text {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.message-bubble.ai .message-text:not(:first-child) {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Structured Message Content */
|
||||
.message-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.message-section:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.message-section-title {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
margin: 0 0 10px 0;
|
||||
padding: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.message-section-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.message-section-item {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #374151;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 8px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.message-section-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Input Container */
|
||||
.input-container {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
padding: 0 100px 0 20px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 12px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #111827;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.chat-input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.chat-input:focus {
|
||||
border-color: #94a3b8;
|
||||
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.send-button {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.send-button:hover {
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.send-button:active {
|
||||
transform: translateY(-50%) scale(0.95);
|
||||
}
|
||||
|
||||
.send-button:focus {
|
||||
outline: 2px solid rgba(148, 163, 184, 0.3);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.send-button svg {
|
||||
stroke-width: 1.5;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.send-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.send-button:disabled:hover {
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.voice-button {
|
||||
position: absolute;
|
||||
right: 52px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.voice-button:hover {
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.voice-button:active {
|
||||
transform: translateY(-50%) scale(0.95);
|
||||
}
|
||||
|
||||
.voice-button:focus {
|
||||
outline: 2px solid rgba(148, 163, 184, 0.3);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.voice-button svg {
|
||||
stroke-width: 1.5;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.voice-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.voice-button:disabled:hover {
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.voice-button .listening {
|
||||
color: #ef4444;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading Indicator */
|
||||
.loading-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 14px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.chat-content {
|
||||
padding: 60px 16px 32px;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
.main-heading {
|
||||
font-size: 28px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.message-bubble.ai {
|
||||
max-width: 95%;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
height: 52px;
|
||||
padding: 0 96px 0 16px;
|
||||
}
|
||||
|
||||
.voice-button {
|
||||
right: 50px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.send-button {
|
||||
right: 10px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
363
src/components/ChatInterface.jsx
Normal file
363
src/components/ChatInterface.jsx
Normal file
@ -0,0 +1,363 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Send, Loader2, Mic } from 'lucide-react'
|
||||
import { sendQuery } from '../services/api'
|
||||
import { formatStructuredMessage } from '../utils/formatMessage'
|
||||
import './ChatInterface.css'
|
||||
|
||||
const ChatInterface = () => {
|
||||
const [messages, setMessages] = useState([])
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isListening, setIsListening] = useState(false)
|
||||
const messagesEndRef = useRef(null)
|
||||
const recognitionRef = useRef(null)
|
||||
const baseInputValueRef = useRef('')
|
||||
|
||||
const getTimeBasedGreeting = () => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour >= 5 && hour < 12) {
|
||||
return 'Hi, Good Morning!'
|
||||
} else if (hour >= 12 && hour < 17) {
|
||||
return 'Hi, Good Afternoon!'
|
||||
} else if (hour >= 17 && hour < 21) {
|
||||
return 'Hi, Good Evening!'
|
||||
} else {
|
||||
return 'Hi, Good Night!'
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
scrollToBottom()
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
// Initialize Speech Recognition
|
||||
useEffect(() => {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition
|
||||
|
||||
if (!SpeechRecognition) {
|
||||
console.warn('Speech recognition not supported in this browser')
|
||||
return
|
||||
}
|
||||
|
||||
const recognition = new SpeechRecognition()
|
||||
recognition.continuous = false
|
||||
recognition.interimResults = true
|
||||
recognition.lang = 'en-US'
|
||||
|
||||
recognition.onstart = () => {
|
||||
setIsListening(true)
|
||||
console.log('Speech recognition started')
|
||||
}
|
||||
|
||||
recognition.onresult = (event) => {
|
||||
let interimTranscript = ''
|
||||
let finalTranscript = ''
|
||||
|
||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||
const transcript = event.results[i][0].transcript
|
||||
if (event.results[i].isFinal) {
|
||||
finalTranscript += transcript + ' '
|
||||
} else {
|
||||
interimTranscript += transcript
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Speech recognition result:', { finalTranscript, interimTranscript })
|
||||
|
||||
// Combine base value with final and interim transcripts
|
||||
const baseText = baseInputValueRef.current
|
||||
let newValue = baseText
|
||||
|
||||
if (finalTranscript) {
|
||||
newValue = (baseText ? baseText + ' ' : '') + finalTranscript.trim()
|
||||
baseInputValueRef.current = newValue // Update base for next recognition
|
||||
console.log('Final transcript added, new value:', newValue)
|
||||
} else if (interimTranscript) {
|
||||
newValue = (baseText ? baseText + ' ' : '') + interimTranscript
|
||||
console.log('Interim transcript added, new value:', newValue)
|
||||
}
|
||||
|
||||
setInputValue(newValue)
|
||||
}
|
||||
|
||||
recognition.onerror = (event) => {
|
||||
console.error('Speech recognition error:', event.error)
|
||||
setIsListening(false)
|
||||
|
||||
if (event.error === 'no-speech') {
|
||||
console.log('No speech detected')
|
||||
// Don't show alert for no-speech, just stop
|
||||
} else if (event.error === 'not-allowed') {
|
||||
alert('Microphone permission denied. Please enable microphone access in your browser settings.')
|
||||
} else if (event.error === 'aborted') {
|
||||
console.log('Speech recognition aborted')
|
||||
} else {
|
||||
console.error('Speech recognition error:', event.error)
|
||||
}
|
||||
}
|
||||
|
||||
recognition.onend = () => {
|
||||
setIsListening(false)
|
||||
console.log('Speech recognition ended')
|
||||
}
|
||||
|
||||
recognitionRef.current = recognition
|
||||
|
||||
return () => {
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
recognitionRef.current.stop()
|
||||
} catch (e) {
|
||||
// Ignore errors when stopping
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSend = async () => {
|
||||
const query = inputValue.trim()
|
||||
if (!query || isLoading) return
|
||||
|
||||
// Add user message to chat
|
||||
const userMessage = {
|
||||
type: 'user',
|
||||
text: query
|
||||
}
|
||||
|
||||
setMessages(prev => [...prev, userMessage])
|
||||
setInputValue('')
|
||||
setIsLoading(true)
|
||||
|
||||
// Add loading message
|
||||
const loadingMessage = {
|
||||
type: 'ai',
|
||||
text: '',
|
||||
isLoading: true
|
||||
}
|
||||
setMessages(prev => [...prev, loadingMessage])
|
||||
|
||||
try {
|
||||
// Call API
|
||||
const response = await sendQuery(query)
|
||||
|
||||
// Replace loading message with actual response
|
||||
const answerText = response.answer || response.message || 'Sorry, I could not process your query.'
|
||||
const structuredMessage = formatStructuredMessage(answerText)
|
||||
|
||||
setMessages(prev => {
|
||||
const newMessages = [...prev]
|
||||
const loadingIndex = newMessages.findIndex(msg => msg.isLoading === true)
|
||||
if (loadingIndex !== -1) {
|
||||
newMessages[loadingIndex] = {
|
||||
type: 'ai',
|
||||
text: answerText,
|
||||
structuredMessage: structuredMessage,
|
||||
imageUrl: response.source?.property_image_url || null,
|
||||
isLoading: false
|
||||
}
|
||||
}
|
||||
return newMessages
|
||||
})
|
||||
} catch (error) {
|
||||
// Replace loading message with error message
|
||||
setMessages(prev => {
|
||||
const newMessages = [...prev]
|
||||
const loadingIndex = newMessages.findIndex(msg => msg.isLoading === true)
|
||||
if (loadingIndex !== -1) {
|
||||
newMessages[loadingIndex] = {
|
||||
type: 'ai',
|
||||
text: 'Sorry, there was an error processing your query. Please try again.',
|
||||
isLoading: false
|
||||
}
|
||||
}
|
||||
return newMessages
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
const handleVoiceToggle = () => {
|
||||
if (!recognitionRef.current) {
|
||||
alert('Speech recognition is not supported in your browser. Please use Chrome, Edge, or Safari.')
|
||||
return
|
||||
}
|
||||
|
||||
if (isListening) {
|
||||
try {
|
||||
recognitionRef.current.stop()
|
||||
setIsListening(false)
|
||||
} catch (error) {
|
||||
console.error('Error stopping speech recognition:', error)
|
||||
setIsListening(false)
|
||||
}
|
||||
} else {
|
||||
// Store current input value as base before starting
|
||||
baseInputValueRef.current = inputValue.trim()
|
||||
console.log('Starting voice recognition, base text:', baseInputValueRef.current)
|
||||
|
||||
try {
|
||||
recognitionRef.current.start()
|
||||
} catch (error) {
|
||||
console.error('Error starting speech recognition:', error)
|
||||
// If recognition is already running, try to stop and restart
|
||||
if (error.message && error.message.includes('already started')) {
|
||||
try {
|
||||
recognitionRef.current.stop()
|
||||
setTimeout(() => {
|
||||
recognitionRef.current.start()
|
||||
}, 100)
|
||||
} catch (retryError) {
|
||||
console.error('Error retrying speech recognition:', retryError)
|
||||
setIsListening(false)
|
||||
}
|
||||
} else {
|
||||
setIsListening(false)
|
||||
alert('Could not start voice recognition. Please check your microphone permissions.')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-container">
|
||||
<div className="chat-content">
|
||||
{/* Header Logo */}
|
||||
<div className="header-logo">
|
||||
<img
|
||||
src="/0x0.png"
|
||||
alt="ONE BROKER GROUP"
|
||||
className="logo-image"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Show greeting and heading only when no messages */}
|
||||
{messages.length === 0 && (
|
||||
<>
|
||||
{/* Greeting Message - Above heading */}
|
||||
<div className="greeting-container">
|
||||
<p className="greeting-text">{getTimeBasedGreeting()}</p>
|
||||
</div>
|
||||
|
||||
{/* Main Heading - Below greeting */}
|
||||
<h1 className="main-heading">
|
||||
Exploring Dubai real estate?<br />
|
||||
Ask our smart chatbot
|
||||
</h1>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Messages Container - Show when there are messages */}
|
||||
{messages.length > 0 && (
|
||||
<div className="messages-container">
|
||||
{messages.map((message, index) => (
|
||||
<div key={index} className={`message-wrapper ${message.type}`}>
|
||||
<div className="message-label">
|
||||
{message.type === 'user' ? 'ME' : 'Chatbot'}
|
||||
</div>
|
||||
<div className={`message-bubble ${message.type}`}>
|
||||
{message.isLoading ? (
|
||||
<div className="loading-indicator">
|
||||
<Loader2 size={20} className="spinner" />
|
||||
<span>Thinking...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{message.imageUrl && (
|
||||
<div className="message-image-container">
|
||||
<img
|
||||
src={message.imageUrl}
|
||||
alt="Property"
|
||||
className="message-image"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{message.structuredMessage && message.structuredMessage.sections.length > 0 ? (
|
||||
<div className="message-content">
|
||||
{message.structuredMessage.intro && (
|
||||
<p className="message-text">{message.structuredMessage.intro}</p>
|
||||
)}
|
||||
{message.structuredMessage.sections.map((section, idx) => (
|
||||
<div key={idx} className="message-section">
|
||||
<h3 className="message-section-title">{section.title}:</h3>
|
||||
<ul className="message-section-list">
|
||||
{section.content.map((item, itemIdx) => (
|
||||
<li key={itemIdx} className="message-section-item">{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : message.text && (
|
||||
<p className="message-text">{message.text}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input Field */}
|
||||
<div className="input-container">
|
||||
<input
|
||||
type="text"
|
||||
className="chat-input"
|
||||
placeholder="Ask me anything about Dubai real estate"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
/>
|
||||
<button
|
||||
className="voice-button"
|
||||
onClick={handleVoiceToggle}
|
||||
type="button"
|
||||
disabled={isLoading}
|
||||
aria-label={isListening ? 'Stop listening' : 'Start voice input'}
|
||||
title={isListening ? 'Stop listening' : 'Start voice input'}
|
||||
>
|
||||
<Mic
|
||||
size={20}
|
||||
strokeWidth={1.5}
|
||||
className={isListening ? 'listening' : ''}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className="send-button"
|
||||
onClick={handleSend}
|
||||
type="button"
|
||||
disabled={isLoading || !inputValue.trim()}
|
||||
aria-label="Send message"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} strokeWidth={1.5} className="spinner" />
|
||||
) : (
|
||||
<Send size={20} strokeWidth={1.5} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatInterface
|
||||
|
||||
17
src/index.css
Normal file
17
src/index.css
Normal file
@ -0,0 +1,17 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
11
src/main.jsx
Normal file
11
src/main.jsx
Normal file
@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
26
src/services/api.js
Normal file
26
src/services/api.js
Normal file
@ -0,0 +1,26 @@
|
||||
const API_BASE_URL = 'https://property-bot.tech4bizsolutions.com/api/v1/property/query'
|
||||
|
||||
export const sendQuery = async (query) => {
|
||||
try {
|
||||
const response = await fetch(API_BASE_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: query,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Error sending query:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
130
src/utils/formatMessage.js
Normal file
130
src/utils/formatMessage.js
Normal file
@ -0,0 +1,130 @@
|
||||
export const formatStructuredMessage = (text) => {
|
||||
if (!text) return null
|
||||
|
||||
// Enhanced section patterns - look for headers with word boundaries to avoid partial matches
|
||||
const sectionPatterns = [
|
||||
{
|
||||
patterns: [/\bproject specifications?:?\b/i],
|
||||
title: 'Project Specifications'
|
||||
},
|
||||
{
|
||||
patterns: [/\bdesign\s*&\s*layouts?:?\b/i, /\bdesign:\s*/i],
|
||||
title: 'Design & Layouts'
|
||||
},
|
||||
{
|
||||
patterns: [/\bunit types?:?\b/i, /\bunits?:\s*/i],
|
||||
title: 'Unit Types'
|
||||
},
|
||||
{
|
||||
patterns: [/\bamenities?:?\b/i, /\bfacilities?:\s*/i],
|
||||
title: 'Amenities'
|
||||
},
|
||||
{
|
||||
patterns: [/\bconnectivity?:?\b/i, /\blocation:\s*/i],
|
||||
title: 'Connectivity'
|
||||
},
|
||||
{
|
||||
patterns: [/\bpayment plan?:?\b/i, /\bpayment:\s*/i],
|
||||
title: 'Payment Plan'
|
||||
},
|
||||
{
|
||||
patterns: [/\bpricing:\s*/i],
|
||||
title: 'Pricing'
|
||||
},
|
||||
{
|
||||
patterns: [/\boverview?:?\b/i],
|
||||
title: 'Overview'
|
||||
},
|
||||
{
|
||||
patterns: [/\bdeveloper?:?\b/i],
|
||||
title: 'Developer'
|
||||
},
|
||||
]
|
||||
|
||||
// Find all section matches
|
||||
const matches = []
|
||||
|
||||
sectionPatterns.forEach(({ patterns, title, keywords }) => {
|
||||
patterns.forEach(pattern => {
|
||||
const match = pattern.exec(text)
|
||||
if (match) {
|
||||
matches.push({ index: match.index, title, match: match[0], keywords })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// If no explicit section headers found, don't try keyword matching
|
||||
// as it can cause incorrect matches and text corruption
|
||||
|
||||
// Remove duplicates and sort by index
|
||||
const uniqueMatches = []
|
||||
const seenTitles = new Set()
|
||||
matches.sort((a, b) => a.index - b.index).forEach(match => {
|
||||
if (!seenTitles.has(match.title)) {
|
||||
seenTitles.add(match.title)
|
||||
uniqueMatches.push(match)
|
||||
}
|
||||
})
|
||||
|
||||
if (uniqueMatches.length === 0) {
|
||||
// If still no matches, check if text contains numbered lists or bullet points
|
||||
// and try to extract structured content
|
||||
const lines = text.split(/\n+/).filter(line => line.trim())
|
||||
const hasListFormat = lines.some(line => /^\d+\.|^[-*•]/.test(line.trim()))
|
||||
|
||||
if (hasListFormat) {
|
||||
// Try to group by topic based on content
|
||||
return null // Let it fall through to plain text for now
|
||||
}
|
||||
|
||||
return { intro: text, sections: [] }
|
||||
}
|
||||
|
||||
// Extract intro text (before first section)
|
||||
const introText = uniqueMatches.length > 0
|
||||
? text.substring(0, uniqueMatches[0].index).trim()
|
||||
: text.trim()
|
||||
|
||||
// Extract each section
|
||||
const sections = []
|
||||
uniqueMatches.forEach((match, index) => {
|
||||
// Find the actual end of the matched pattern (including colon if present)
|
||||
let startIndex = match.index + match.match.length
|
||||
|
||||
// Skip any whitespace or colons after the match
|
||||
while (startIndex < text.length && /[\s:]/.test(text[startIndex])) {
|
||||
startIndex++
|
||||
}
|
||||
|
||||
const endIndex = index < uniqueMatches.length - 1
|
||||
? uniqueMatches[index + 1].index
|
||||
: text.length
|
||||
let content = text.substring(startIndex, endIndex).trim()
|
||||
|
||||
// Clean up content - only remove leading colons/dashes if they're at the very start
|
||||
content = content.replace(/^[:\-–—]\s+/, '').trim()
|
||||
|
||||
// Split into lines and format
|
||||
const lines = content.split(/\n+/).filter(line => line.trim())
|
||||
const formattedContent = lines.map(line => {
|
||||
const originalLine = line
|
||||
// Only remove list markers if they're actually at the start
|
||||
line = line.replace(/^\d+\.\s+/, '') // Numbered lists (1. )
|
||||
.replace(/^[-*•]\s+/, '') // Bullet points
|
||||
.replace(/^[:\-–—]\s+/, '') // Colons/dashes
|
||||
// If we removed something, trim, otherwise use original
|
||||
line = line !== originalLine ? line.trim() : line.trim()
|
||||
return line
|
||||
}).filter(line => line.length > 0)
|
||||
|
||||
if (formattedContent.length > 0) {
|
||||
sections.push({
|
||||
title: match.title,
|
||||
content: formattedContent
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return { intro: introText, sections }
|
||||
}
|
||||
|
||||
7
vite.config.js
Normal file
7
vite.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user