Spaces:
Paused
Paused
| import React, { useState, useEffect, useRef } from "react"; | |
| import ReactMarkdown from "react-markdown"; | |
| import FileUploadCard from "./FileUploadCard"; | |
| const DeleteBinButton = ({ onClick }) => ( | |
| <button className="delete-bin-btn" onClick={onClick} title="Delete chat"> | |
| <svg viewBox="0 0 69 14" className="svgIcon bin-top"> | |
| <path d="M20.8232 2.62734L19.9948 4.21304C19.8224 4.54309 19.4808 4.75 19.1085 4.75H4.92857C2.20246 4.75 0 6.87266 0 9.5C0 12.1273 2.20246 14.25 4.92857 14.25H64.0714C66.7975 14.25 69 12.1273 69 9.5C69 6.87266 66.7975 4.75 64.0714 4.75H49.8915C49.5192 4.75 49.1776 4.54309 49.0052 4.21305L48.1768 2.62734Z" /> | |
| </svg> | |
| <svg viewBox="0 0 69 57" className="svgIcon bin-bottom"> | |
| <path d="M64.0023 1.0648C64.0397 0.4882 63.5822 0 63.0044 0H5.99556C5.4178 0 4.96025 0.4882 4.99766 1.0648L8.19375 50.3203C8.44018 54.0758 11.6746 57 15.5712 57H53.4288C57.3254 57 60.5598 54.0758 60.8062 50.3203L64.0023 1.0648Z" /> | |
| </svg> | |
| </button> | |
| ); | |
| export default function App() { | |
| // <-- Set your backend URL here (the .hf.space backend) | |
| const API_BASE = "https://durga-7780-chatbot.hf.space"; | |
| const [allChats, setAllChats] = useState({}); | |
| const [messages, setMessages] = useState([]); // {id, role, text} | |
| const [input, setInput] = useState(""); | |
| const [uploading, setUploading] = useState(false); | |
| const [files, setFiles] = useState([]); | |
| const [uploadProgress, setUploadProgress] = useState({}); | |
| const [sessionId, setSessionId] = useState(() => { | |
| return localStorage.getItem("rag_session") || Math.random().toString(36).slice(2, 10); | |
| }); | |
| const [loading, setLoading] = useState(false); | |
| const [error, setError] = useState(null); | |
| const [isRecording, setIsRecording] = useState(false); | |
| const [isTranscribing, setIsTranscribing] = useState(false); | |
| const messagesEndRef = useRef(null); | |
| const mediaRecorderRef = useRef(null); | |
| const audioChunksRef = useRef([]); | |
| const justTranscribedRef = useRef(false); | |
| const [chatHistory, setChatHistory] = useState([]); | |
| const [warning, setWarning] = useState(""); | |
| const [sttCooldown, setSttCooldown] = useState(false); | |
| useEffect(() => { | |
| const savedChats = JSON.parse(localStorage.getItem("allChats") || "{}"); | |
| const savedHistory = JSON.parse(localStorage.getItem("chatHistory") || "[]"); | |
| setAllChats(savedChats); | |
| setChatHistory(savedHistory); | |
| if (sessionId && savedChats[sessionId]) { | |
| setMessages(savedChats[sessionId]); | |
| } | |
| }, []); | |
| useEffect(() => { | |
| localStorage.setItem("allChats", JSON.stringify(allChats)); | |
| }, [allChats]); | |
| useEffect(() => { | |
| localStorage.setItem("chatHistory", JSON.stringify(chatHistory)); | |
| }, [chatHistory]); | |
| useEffect(() => { | |
| localStorage.setItem("rag_session", sessionId); | |
| }, [sessionId]); | |
| useEffect(() => { | |
| scrollToBottom(); | |
| }, [messages, loading]); | |
| useEffect(() => { | |
| // Inject minimal CSS | |
| const css = ` | |
| :root{ --bg:#0f1724; --card:#0b1220; --accent:#6ee7b7; --muted:#94a3b8; } | |
| *{box-sizing:border-box} | |
| html,body,#root{height:100%;} | |
| body{margin:0;font-family:Inter,ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial;background:linear-gradient(180deg,#071029 0%, #071b2e 100%);color:#e6eef8} | |
| .app{display:flex;height:100vh;padding:24px;gap:24px} | |
| .left{width:360px;background:rgba(255,255,255,0.03);border-radius:12px;padding:16px;display:flex;flex-direction:column;gap:12px} | |
| .brand{font-weight:700;font-size:18px} | |
| .upload-area{background:rgba(255,255,255,0.02);padding:12px;border-radius:8px;border:1px dashed rgba(255,255,255,0.03);display:flex;flex-direction:column;gap:8px} | |
| .files-list{display:flex;flex-direction:column;gap:6px;max-height:220px;overflow:auto} | |
| .file-item{display:flex;justify-content:space-between;align-items:center;padding:8px;border-radius:8px;background:rgba(255,255,255,0.01)} | |
| .btn{background:var(--accent);color:#042024;padding:8px 12px;border-radius:8px;border:0;cursor:pointer;font-weight:600} | |
| .btn:disabled{opacity:0.5;cursor:not-allowed} | |
| .right{flex:1;display:flex;flex-direction:column;border-radius:12px;background:rgba(255,255,255,0.02);padding:12px;gap:12px} | |
| .chat-window{flex:1;overflow:auto;padding:12px;display:flex;flex-direction:column;gap:12px} | |
| .message-row{display:flex;width:100%} | |
| /* user RIGHT, ai LEFT */ | |
| .message-row.user{justify-content:flex-end} | |
| .message-row.ai{justify-content:flex-start} | |
| .bubble { | |
| max-width: 70%; | |
| padding: 8px 10px; | |
| border-radius: 12px; | |
| box-shadow: 0 4px 14px rgba(2,6,23,0.6); | |
| line-height: 1.5; | |
| word-break: break-word; | |
| } | |
| /* user bubble appears on right (bright) */ | |
| .bubble.user { | |
| background: #2563eb; | |
| color: white; | |
| margin-left: auto; | |
| } | |
| /* ai bubble appears on left (dark) */ | |
| .bubble.ai{ | |
| background:#0f1724; | |
| color:#e6eef8; | |
| border-top-right-radius:12px; | |
| border-top-left-radius:4px; | |
| } | |
| .text-input { | |
| flex: 1; | |
| min-height: 44px; | |
| max-height: 120px; | |
| padding: 12px 14px; | |
| border-radius: 10px; | |
| border: 1px solid rgba(255,255,255,0.12); | |
| background: rgba(255,255,255,0.06); | |
| color: #e6eef8; | |
| font-size: 14px; | |
| resize: none; | |
| } | |
| .text-input::placeholder { | |
| color: rgba(255,255,255,0.5); | |
| } | |
| .input-row { | |
| display: flex; | |
| align-items: flex-end; | |
| gap: 10px; | |
| } | |
| .switch { | |
| position: relative; | |
| width: 48px; | |
| height: 48px; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| background-color: rgb(60,64,67); | |
| color: #fff; | |
| border-radius: 50%; | |
| cursor: pointer; | |
| transition: all .3s cubic-bezier(0.175, 0.885, 0.32, 1.275); | |
| } | |
| .mic-on, .mic-off { | |
| width: 100%; | |
| height: 100%; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| transition: all .3s ease-in-out; | |
| } | |
| .mic-on { z-index: 4; } | |
| .mic-off { | |
| position: absolute; | |
| inset: 0; | |
| z-index: 5; | |
| opacity: 0; | |
| } | |
| .switch:hover { | |
| background-color: rgba(60,64,67,0.8); | |
| } | |
| #mic-toggle { | |
| display: none; | |
| } | |
| #mic-toggle:checked + .switch { | |
| background-color: red; | |
| } | |
| #mic-toggle:checked + .switch .mic-off { | |
| opacity: 1; | |
| } | |
| #mic-toggle:checked + .switch .mic-on { | |
| opacity: 0; | |
| } | |
| #mic-toggle:active + .switch { | |
| scale: 1.2; | |
| } | |
| .history-item { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 8px; | |
| padding: 8px; | |
| border-radius: 6px; | |
| margin-bottom: 6px; | |
| cursor: pointer; | |
| transition: background 0.2s ease; | |
| } | |
| .history-item:hover { | |
| background: rgba(255,255,255,0.08); | |
| } | |
| .history-title { | |
| flex: 1; | |
| font-size: 13px; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .history-delete { | |
| flex-shrink: 0; | |
| } | |
| .history-item { | |
| min-height: 36px; | |
| } | |
| .history-title { | |
| flex: 1; | |
| overflow: hidden; | |
| white-space: nowrap; | |
| text-overflow: ellipsis; | |
| } | |
| .delete-bin-btn { | |
| width: 28px; | |
| height: 28px; | |
| border-radius: 6px; | |
| border: none; | |
| background: rgba(255, 95, 95, 0.15); | |
| cursor: pointer; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 2px; | |
| padding: 0; | |
| } | |
| .delete-bin-btn:hover { | |
| background: rgba(255, 95, 95, 0.35); | |
| } | |
| .delete-bin-btn .svgIcon { | |
| width: 10px; | |
| transition: 0.3s; | |
| } | |
| .delete-bin-btn .svgIcon path { | |
| fill: white; | |
| } | |
| .delete-bin-btn .bin-top { | |
| transform-origin: bottom right; | |
| } | |
| .delete-bin-btn:hover .bin-top { | |
| transform: rotate(160deg); | |
| } | |
| .delete-bin-btn:focus-visible { | |
| outline: 2px solid #ff5f5f; | |
| outline-offset: 2px; | |
| } | |
| .delete-bin-btn { | |
| background: rgba(255, 0, 0, 0.25); | |
| } | |
| .delete-bin-btn:hover { | |
| background: rgba(255, 0, 0, 0.6); | |
| } | |
| .delete-bin-btn .svgIcon path { | |
| fill: #ff3b3b; | |
| } | |
| .delete-bin-btn:hover .svgIcon path { | |
| fill: #ffffff; | |
| } | |
| .chat-history-scroll { | |
| overflow-y: auto; | |
| flex: 1; | |
| padding-right: 4px; | |
| max-height: calc(100vh - 420px); | |
| } | |
| `; | |
| const style = document.createElement("style"); | |
| style.innerHTML = css; | |
| document.head.appendChild(style); | |
| return () => { | |
| if (style && style.parentNode) style.parentNode.removeChild(style); | |
| }; | |
| }, []); | |
| function scrollToBottom() { | |
| if (messagesEndRef.current) { | |
| messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); | |
| } | |
| } | |
| const handleFiles = (e) => { | |
| const fileList = Array.from(e.target.files); | |
| setFiles((prev) => [...prev, ...fileList]); | |
| }; | |
| const removeFile = (index) => { | |
| setFiles((prev) => prev.filter((_, i) => i !== index)); | |
| }; | |
| const loadChat = (id) => { | |
| setSessionId(id); | |
| const msgs = allChats[id] || []; | |
| setMessages(msgs); | |
| localStorage.setItem("rag_session", id); | |
| }; | |
| const uploadFiles = async () => { | |
| if (!files.length) return; | |
| setUploading(true); | |
| setError(null); | |
| try { | |
| const form = new FormData(); | |
| files.forEach((f) => form.append("files", f)); | |
| form.append("session_id", sessionId); | |
| const xhr = new XMLHttpRequest(); | |
| xhr.open("POST", `${API_BASE}/api/upload`); | |
| xhr.upload.onprogress = (event) => { | |
| if (event.lengthComputable) { | |
| const percent = Math.round((event.loaded / event.total) * 100); | |
| // Update progress for all files equally (or enhance per-file) | |
| const newProgress = {}; | |
| files.forEach((f) => { | |
| newProgress[f.name] = percent; | |
| }); | |
| setUploadProgress(newProgress); | |
| } | |
| }; | |
| xhr.onload = () => { | |
| if (xhr.status >= 200 && xhr.status < 300) { | |
| const data = JSON.parse(xhr.responseText); | |
| addSystemMessage( | |
| `Uploaded ${data.indexed_files?.length || files.length} file(s). Indexed successfully.` | |
| ); | |
| setFiles([]); | |
| setUploadProgress({}); | |
| } else { | |
| throw new Error(xhr.responseText || `HTTP ${xhr.status}`); | |
| } | |
| setUploading(false); | |
| }; | |
| xhr.onerror = () => { | |
| setError("Upload failed"); | |
| setUploading(false); | |
| }; | |
| xhr.send(form); | |
| } catch (err) { | |
| console.error(err); | |
| setError(err.message || "Upload failed"); | |
| setUploading(false); | |
| } | |
| }; | |
| const addSystemMessage = (text) => { | |
| const msg = { id: Date.now() + Math.random(), role: "system", text }; | |
| setMessages((m) => [...m, msg]); | |
| setAllChats(prev => ({ | |
| ...prev, | |
| [sessionId]: [...(prev[sessionId] || []), msg] | |
| })); | |
| }; | |
| const sendMessage = async () => { | |
| if (!input.trim()) { | |
| setWarning("Please enter a message before sending"); | |
| setTimeout(() => { | |
| setWarning(""); | |
| }, 2000); | |
| return; | |
| } | |
| // add user message locally immediately | |
| const userMsg = { id: Date.now() + Math.random(), role: "user", text: input }; | |
| setMessages((m) => [...m, userMsg]); | |
| setAllChats(prev => ({ | |
| ...prev, | |
| [sessionId]: [...(prev[sessionId] || []), userMsg] | |
| })); | |
| const query = input; | |
| setInput(""); | |
| setLoading(true); | |
| setError(null); | |
| try { | |
| // include recent history so backend can handle follow-ups | |
| const recentHistory = messages.slice(-8).map((m) => { | |
| return { role: m.role, text: m.text }; | |
| }); | |
| const form = new FormData(); | |
| form.append("session_id", sessionId); | |
| form.append("message", query); | |
| form.append("top_k", "6"); | |
| form.append("history", JSON.stringify(recentHistory)); | |
| setChatHistory(prev => { | |
| if (prev.some(c => c.id === sessionId)) { | |
| return prev; | |
| } | |
| return [ | |
| { | |
| id: sessionId, | |
| title: query.trim().slice(0, 40) | |
| }, | |
| ...prev | |
| ]; | |
| }); | |
| const res = await fetch(`${API_BASE}/api/chat`, { | |
| method: "POST", | |
| body: form, | |
| }); | |
| if (!res.ok) { | |
| const contentType = res.headers.get("content-type") || ""; | |
| let text = await res.text(); | |
| try { | |
| if (contentType.includes("application/json")) { | |
| const j = JSON.parse(text); | |
| text = j.message || JSON.stringify(j); | |
| } | |
| } catch (e) { | |
| /* ignore */ | |
| } | |
| throw new Error(text || `HTTP ${res.status}`); | |
| } | |
| const data = await res.json(); | |
| const aiMsg = { | |
| id: Date.now() + Math.random(), | |
| role: "ai", | |
| text: data.answer || data.result || "No answer.", | |
| }; | |
| setMessages((m) => [...m, aiMsg]); | |
| setAllChats(prev => ({ | |
| ...prev, | |
| [sessionId]: [...(prev[sessionId] || []), aiMsg] | |
| })); | |
| } catch (err) { | |
| console.error(err); | |
| setError(err.message || "Chat failed"); | |
| addSystemMessage(`Error: ${err.message || "Chat failed"}`); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| const handleKeyDown = (e) => { | |
| if (justTranscribedRef.current) { | |
| justTranscribedRef.current = false; | |
| return; | |
| } | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| sendMessage(); | |
| } | |
| }; | |
| const startRecording = async () => { | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| const mediaRecorder = new MediaRecorder(stream); | |
| mediaRecorderRef.current = mediaRecorder; | |
| audioChunksRef.current = []; | |
| mediaRecorder.ondataavailable = (event) => { | |
| if (event.data.size > 0) { | |
| audioChunksRef.current.push(event.data); | |
| } | |
| }; | |
| mediaRecorder.onstop = async () => { | |
| const audioBlob = new Blob(audioChunksRef.current, { type: "audio/webm" }); | |
| await transcribeAudio(audioBlob); | |
| stream.getTracks().forEach((track) => track.stop()); | |
| }; | |
| mediaRecorder.start(); | |
| setIsRecording(true); | |
| setError(null); | |
| } catch (err) { | |
| console.error("Error accessing microphone:", err); | |
| setError("Microphone access denied. Please allow microphone permissions."); | |
| addSystemMessage("Error: Microphone access denied"); | |
| } | |
| }; | |
| const stopRecording = () => { | |
| if (mediaRecorderRef.current && isRecording) { | |
| mediaRecorderRef.current.stop(); | |
| setIsRecording(false); | |
| } | |
| }; | |
| const transcribeAudio = async (audioBlob) => { | |
| setIsTranscribing(true); | |
| setError(null); | |
| try { | |
| const form = new FormData(); | |
| form.append("audio", audioBlob, "recording.webm"); | |
| form.append("session_id", sessionId); | |
| const res = await fetch(`${API_BASE}/api/stt`, { | |
| method: "POST", | |
| body: form, | |
| }); | |
| if (!res.ok) { | |
| const contentType = res.headers.get("content-type") || ""; | |
| let text = await res.text(); | |
| try { | |
| if (contentType.includes("application/json")) { | |
| const j = JSON.parse(text); | |
| text = j.message || j.error || JSON.stringify(j); | |
| } | |
| } catch (_) {} | |
| throw new Error(text || `HTTP ${res.status}`); | |
| } | |
| const data = await res.json(); | |
| const transcribedText = | |
| data.text || data.transcription || data.result || ""; | |
| if (transcribedText.trim()) { | |
| justTranscribedRef.current = true; | |
| setInput((prev) => | |
| prev ? `${prev} ${transcribedText}` : transcribedText | |
| ); | |
| setSttCooldown(true); | |
| setTimeout(() => setSttCooldown(false), 500); | |
| } | |
| } catch (err) { | |
| console.error("Transcription error:", err); | |
| setError(err.message || "Transcription failed"); | |
| addSystemMessage(`Error: ${err.message || "Transcription failed"}`); | |
| } finally { | |
| setIsTranscribing(false); | |
| } | |
| }; | |
| const toggleRecording = () => { | |
| if (isRecording) { | |
| stopRecording(); | |
| } else { | |
| startRecording(); | |
| } | |
| }; | |
| const deleteChat = (id) => { | |
| setChatHistory(prev => prev.filter(c => c.id !== id)); | |
| setAllChats(prev => { | |
| const copy = { ...prev }; | |
| delete copy[id]; | |
| return copy; | |
| }); | |
| if (sessionId === id) { | |
| setMessages([]); | |
| } | |
| }; | |
| const startNewChat = () => { | |
| const newSession = Math.random().toString(36).slice(2, 10); | |
| setSessionId(newSession); | |
| setMessages([]); | |
| setInput(""); | |
| setChatHistory(prev => [ | |
| { | |
| id: newSession, | |
| title: "New Chat" | |
| }, | |
| ...prev | |
| ]); | |
| localStorage.setItem("rag_session", newSession); | |
| }; | |
| return ( | |
| <div className="app"> | |
| {/* ================= LEFT PANEL ================= */} | |
| {/* 🔴 WARNING OVERLAY – PLACE HERE */} | |
| {warning && ( | |
| <div | |
| style={{ | |
| position: "fixed", | |
| inset: 0, | |
| display: "flex", | |
| alignItems: "center", | |
| justifyContent: "center", | |
| zIndex: 9999, | |
| background: "rgba(0,0,0,0.35)", | |
| }} | |
| > | |
| <div | |
| style={{ | |
| display: "flex", | |
| alignItems: "center", | |
| gap: "10px", | |
| padding: "14px 18px", | |
| borderRadius: "10px", | |
| border: "2px solid #ff3b3b", | |
| background: "rgba(255,59,59,0.12)", | |
| color: "#ff3b3b", | |
| boxShadow: "0 0 0 1px rgba(255,59,59,.6), 0 12px 30px rgba(255,59,59,.4)", | |
| fontWeight: 600, | |
| }} | |
| > | |
| <svg | |
| stroke="currentColor" | |
| fill="none" | |
| strokeWidth="2" | |
| viewBox="0 0 24 24" | |
| width="26" | |
| height="26" | |
| > | |
| <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" /> | |
| <path d="M12 9v4" /> | |
| <path d="M12 17h.01" /> | |
| </svg> | |
| {warning} | |
| </div> | |
| </div> | |
| )} | |
| <div className="left"> | |
| <button | |
| onClick={startNewChat} | |
| style={{ | |
| marginTop: "8px", | |
| marginBottom: "12px", | |
| padding: "10px", | |
| borderRadius: "8px", | |
| background: "rgba(255,255,255,0.08)", | |
| color: "white", | |
| border: "1px solid rgba(255,255,255,0.12)", | |
| cursor: "pointer", | |
| fontWeight: 600 | |
| }} | |
| > | |
| New Chat | |
| </button> | |
| <div className="brand">AskMate</div> | |
| {/* -------- Upload Area -------- */} | |
| <div className="upload-area"> | |
| <FileUploadCard | |
| files={files} | |
| onFilesChange={setFiles} | |
| uploadProgress={uploadProgress} | |
| uploading={uploading} | |
| onUpload={uploadFiles} | |
| /> | |
| </div> | |
| {/* -------- Chat History -------- */} | |
| <div style={{ marginTop: 16, display: "flex", flexDirection: "column", flex: 1 }}> | |
| <div style={{ fontWeight: 600, marginBottom: 8 }}> | |
| Chat History | |
| </div> | |
| <div className="chat-history-scroll"> | |
| {chatHistory.length === 0 && ( | |
| <div className="small">No chats yet</div> | |
| )} | |
| {chatHistory.map((chat) => ( | |
| <div | |
| key={chat.id} | |
| className="history-item" | |
| style={{ | |
| background: | |
| chat.id === sessionId | |
| ? "rgba(110,231,183,0.2)" | |
| : "rgba(255,255,255,0.05)", | |
| }} | |
| > | |
| <span | |
| className="history-title" | |
| onClick={() => loadChat(chat.id)} | |
| > | |
| {chat.title} | |
| </span> | |
| <DeleteBinButton onClick={() => deleteChat(chat.id)} /> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| {/* -------- Chat History -------- */} | |
| <div | |
| style={{ | |
| marginTop: "auto", | |
| fontSize: 13, | |
| color: "var(--muted)", | |
| }} | |
| > | |
| Tip: Upload your lecture PDFs first. Then ask focused questions. | |
| </div> | |
| </div> | |
| {/* ================= RIGHT PANEL ================= */} | |
| <div className="right"> | |
| <div | |
| style={{ | |
| display: "flex", | |
| justifyContent: "space-between", | |
| alignItems: "center", | |
| }} | |
| > | |
| <div style={{ fontWeight: 700 }}>Chat</div> | |
| <div className="small">AskMate</div> | |
| </div> | |
| <div className="chat-window"> | |
| {messages.map((m) => ( | |
| <div | |
| key={m.id} | |
| className={`message-row ${ | |
| m.role === "user" | |
| ? "user" | |
| : m.role === "ai" | |
| ? "ai" | |
| : "system" | |
| }`} | |
| > | |
| <div | |
| className={`bubble ${ | |
| m.role === "user" ? "user" : m.role === "ai" ? "ai" : "" | |
| }`} | |
| > | |
| <ReactMarkdown>{m.text}</ReactMarkdown> | |
| {m.role === "system" && ( | |
| <div className="meta">{m.text}</div> | |
| )} | |
| </div> | |
| </div> | |
| ))} | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| <div style={{ display: "flex", flexDirection: "column", gap: 8 }}> | |
| <div className="input-row"> | |
| <textarea | |
| className="text-input" | |
| rows={2} | |
| placeholder="Ask a question (Shift+Enter for new line)" | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| /> | |
| <input | |
| type="checkbox" | |
| id="mic-toggle" | |
| checked={isRecording} | |
| readOnly | |
| /> | |
| <label | |
| htmlFor="mic-toggle" | |
| className="switch" | |
| onClick={toggleRecording} | |
| title={isRecording ? "Stop recording" : "Start recording"} | |
| style={{ opacity: isTranscribing ? 0.5 : 1, pointerEvents: isTranscribing ? "none" : "auto" }} | |
| > | |
| {/* Mic ON */} | |
| <div className="mic-on"> | |
| <svg | |
| xmlns="http://www.w3.org/2000/svg" | |
| width="22" | |
| height="22" | |
| fill="currentColor" | |
| viewBox="0 0 16 16" | |
| > | |
| <path d="M5 3a3 3 0 0 1 6 0v5a3 3 0 0 1-6 0V3z" /> | |
| <path d="M3.5 6.5A.5.5 0 0 1 4 7v1a4 4 0 0 0 8 0V7a.5.5 0 0 1 1 0v1a5 5 0 0 1-4.5 4.975V15h3a.5.5 0 0 1 0 1h-7a.5.5 0 0 1 0-1h3v-2.025A5 5 0 0 1 3 8V7a.5.5 0 0 1 .5-.5z" /> | |
| </svg> | |
| </div> | |
| {/* Mic OFF / Recording */} | |
| <div className="mic-off"> | |
| <svg | |
| xmlns="http://www.w3.org/2000/svg" | |
| width="22" | |
| height="22" | |
| fill="currentColor" | |
| viewBox="0 0 16 16" | |
| > | |
| <path d="M13 8c0 .564-.094 1.107-.266 1.613l-.814-.814A4.02 4.02 0 0 0 12 8V7a.5.5 0 0 1 1 0v1z" /> | |
| <path d="M9.486 10.607 5 6.12V8a3 3 0 0 0 4.486 2.607z" /> | |
| <path d="M11 3v4.879L5.158 2.037A3.001 3.001 0 0 1 11 3z" /> | |
| <path d="M1.646 1.646l12 12 .708-.708-12-12-.708.708z" /> | |
| </svg> | |
| </div> | |
| </label> | |
| <button | |
| className="btn" | |
| disabled={loading || isRecording || sttCooldown} | |
| onClick={sendMessage} | |
| > | |
| {loading ? "Thinking..." : "Send"} | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } |