diff --git a/.github/example.png b/.github/example.png deleted file mode 100644 index d90795b..0000000 Binary files a/.github/example.png and /dev/null differ diff --git a/ChatGPT_UI_Ideas/c1.webp b/ChatGPT_UI_Ideas/c1.webp deleted file mode 100644 index f680976..0000000 Binary files a/ChatGPT_UI_Ideas/c1.webp and /dev/null differ diff --git a/ChatGPT_UI_Ideas/c2.webp b/ChatGPT_UI_Ideas/c2.webp deleted file mode 100644 index f325bee..0000000 Binary files a/ChatGPT_UI_Ideas/c2.webp and /dev/null differ diff --git a/ChatGPT_UI_Ideas/c3.webp b/ChatGPT_UI_Ideas/c3.webp deleted file mode 100644 index a6fc545..0000000 Binary files a/ChatGPT_UI_Ideas/c3.webp and /dev/null differ diff --git a/client/index.css b/client/index.css index 2075cdb..db3cdea 100644 --- a/client/index.css +++ b/client/index.css @@ -1,183 +1,612 @@ -@import "tailwindcss"; +@import 'tailwindcss'; + +/* ============================================================================ + EchoAI · "Midnight Indigo" — premium SaaS workspace + Slate/graphite surfaces · one electric-indigo accent · crisp 14px geometry + Dark-first, with a premium light variant (.theme-light) + ========================================================================== */ + +:root { + /* Dark (default) */ + --bg: #0b0d12; + --bg-2: #090a0e; + --panel: #14171f; + --surface: #14171f; + --surface-2: #1a1e28; + --surface-3: #232834; + --border: rgba(255, 255, 255, 0.07); + --border-strong: rgba(255, 255, 255, 0.13); + + --text: #e6e9ef; + --text-dim: #99a0b2; + --text-faint: rgba(230, 233, 239, 0.42); + + --accent: #6366f1; + --accent-2: #818cf8; + --accent-ink: #aab2fc; + --accent-soft: rgba(99, 102, 241, 0.16); + --accent-ring: rgba(99, 102, 241, 0.5); + --on-accent: #ffffff; + + /* orb pastels — indigo marble */ + --p1: #818cf8; + --p2: #a5b4fc; + --p3: #38bdf8; + --p4: #c4b5fd; + + --danger: #f0708a; + --danger-soft: rgba(240, 112, 138, 0.16); + + --r-xl: 18px; + --r-lg: 16px; + --r-md: 14px; + --r-sm: 10px; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-md: 0 10px 34px -14px rgba(0, 0, 0, 0.66); + --shadow-lg: 0 30px 70px -30px rgba(0, 0, 0, 0.85); + + --font-display: 'Fraunces', Georgia, 'Times New Roman', serif; + --font-body: 'Plus Jakarta Sans', ui-sans-serif, system-ui, sans-serif; + + color-scheme: dark; +} + +/* Premium light variant */ +.theme-light { + --bg: #ffffff; + --bg-2: #f7f8fa; + --panel: #f7f8fa; + --surface: #ffffff; + --surface-2: #f4f5f8; + --surface-3: #eceef3; + --border: rgba(11, 13, 18, 0.08); + --border-strong: rgba(11, 13, 18, 0.14); + + --text: #0b0d12; + --text-dim: #5b6170; + --text-faint: rgba(11, 13, 18, 0.4); + + --accent: #6366f1; + --accent-2: #4f46e5; + --accent-ink: #4f46e5; + --accent-soft: rgba(99, 102, 241, 0.1); + --accent-ring: rgba(99, 102, 241, 0.32); + --on-accent: #ffffff; + + --danger: #dc5670; + --danger-soft: rgba(220, 86, 112, 0.1); + + --shadow-sm: 0 1px 2px rgba(11, 13, 18, 0.05), 0 2px 8px rgba(11, 13, 18, 0.04); + --shadow-md: 0 10px 32px -14px rgba(11, 13, 18, 0.18); + --shadow-lg: 0 30px 64px -30px rgba(11, 13, 18, 0.24); + + color-scheme: light; +} * { box-sizing: border-box; - scroll-behavior: smooth; } -html, body { +html, +body { height: 100%; margin: 0; padding: 0; overflow-x: hidden; } +html { + scroll-behavior: smooth; +} + +body { + background: var(--bg); + color: var(--text); + font-family: var(--font-body); + font-size: 15px; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + #root { height: 100%; min-height: 100vh; } -/* Custom scrollbar */ -::-webkit-scrollbar { - width: 6px; +::selection { + background: var(--accent-soft); + color: var(--text); } -::-webkit-scrollbar-track { - background: transparent; +.font-display { + font-family: var(--font-display); + font-optical-sizing: auto; + letter-spacing: -0.01em; } -::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.2); - border-radius: 3px; +/* ---------------------------------------------------------------------------- + Ambient background — a single soft indigo glow + -------------------------------------------------------------------------- */ +.aura { + position: fixed; + inset: 0; + z-index: 0; + background: + radial-gradient(50% 34% at 62% 4%, var(--accent-soft), transparent 62%), + radial-gradient(70% 50% at 85% -12%, var(--accent-soft), transparent 70%), + var(--bg); } -::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.3); +/* ---------------------------------------------------------------------------- + THE ORB — soft indigo glass marble + -------------------------------------------------------------------------- */ +.orb { + position: relative; + width: var(--orb-size, 116px); + height: var(--orb-size, 116px); + flex: 0 0 auto; } - -/* Light mode scrollbar */ -.light-mode ::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.2); +.orb__halo { + position: absolute; + inset: -26%; + border-radius: 50%; + background: conic-gradient(from 200deg, var(--p1), var(--p2), var(--p3), var(--p4), var(--p1)); + filter: blur(30px); + opacity: 0.5; + animation: halo 16s ease-in-out infinite; } - -.light-mode ::-webkit-scrollbar-thumb:hover { - background: rgba(0, 0, 0, 0.3); +.orb__ball { + position: absolute; + inset: 0; + border-radius: 50%; + background: + radial-gradient(circle at 34% 28%, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0) 42%), + linear-gradient(145deg, var(--p1) 0%, var(--p2) 40%, var(--p3) 74%, var(--p4) 100%); + box-shadow: + inset 0 -8px 22px rgba(56, 50, 120, 0.4), + inset 0 8px 16px rgba(255, 255, 255, 0.4), + var(--shadow-md); } - -/* Smooth animations */ -@keyframes fadeInUp { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } +.orb__sheen { + position: absolute; + top: 13%; + left: 19%; + width: 32%; + height: 21%; + border-radius: 50%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.92), transparent 70%); + filter: blur(2px); } +.orb--breathe { animation: breathe 7s ease-in-out infinite; } +.orb--thinking .orb__halo { opacity: 0.8; animation-duration: 6s; } +.orb--thinking { animation: breathe 2.8s ease-in-out infinite; } +.orb--sm { --orb-size: 28px; } +.orb--sm .orb__halo { filter: blur(8px); inset: -18%; } -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } +@keyframes halo { + 0%, 100% { transform: rotate(0deg) scale(1); } + 50% { transform: rotate(40deg) scale(1.08); } } - -@keyframes pulse { - 0%, 100% { - opacity: 1; - } - 50% { - opacity: 0.5; - } +@keyframes breathe { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.035); } } -@keyframes bounce { - 0%, 20%, 53%, 80%, 100% { - transform: translate3d(0, 0, 0); - } - 40%, 43% { - transform: translate3d(0, -8px, 0); - } - 70% { - transform: translate3d(0, -4px, 0); - } - 90% { - transform: translate3d(0, -2px, 0); - } +/* ---------------------------------------------------------------------------- + Workspace shell — sidebar + -------------------------------------------------------------------------- */ +.sidebar { + display: flex; + flex-direction: column; + width: 274px; + flex: 0 0 274px; + height: 100%; + background: var(--panel); + border-right: 1px solid var(--border); } -.animate-fadeInUp { - animation: fadeInUp 0.6s ease-out; +.scrim { + position: fixed; + inset: 0; + z-index: 30; + background: rgba(4, 5, 8, 0.55); + backdrop-filter: blur(2px); } -.animate-fadeIn { - animation: fadeIn 0.4s ease-out; +.nav-item { + position: relative; + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 9px 11px; + border-radius: var(--r-sm); + color: var(--text-dim); + cursor: pointer; + border: 1px solid transparent; + transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease; +} +.nav-item:hover { + background: var(--surface-2); + color: var(--text); +} +.nav-item--active { + background: var(--accent-soft); + color: var(--text); + border-color: var(--accent-ring); +} +.nav-item__title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13.5px; + font-weight: 500; +} +.nav-actions { + display: flex; + align-items: center; + gap: 2px; + opacity: 0; + transition: opacity 0.2s ease; } +.nav-item:hover .nav-actions, +.nav-item--active .nav-actions { opacity: 1; } +.nav-action { + display: grid; + place-items: center; + width: 26px; + height: 26px; + border-radius: 8px; + color: var(--text-faint); + transition: background 0.18s ease, color 0.18s ease; +} +.nav-action:hover { background: var(--surface-3); color: var(--text); } +.nav-action--danger:hover { color: var(--danger); } -.animate-pulse-slow { - animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +.nav-edit { + flex: 1; + min-width: 0; + background: var(--bg); + border: 1px solid var(--accent-ring); + border-radius: 8px; + padding: 4px 8px; + color: var(--text); + font-family: var(--font-body); + font-size: 13.5px; + outline: none; } -.animate-bounce-slow { - animation: bounce 2s infinite; +.profile-row { + display: flex; + align-items: center; + gap: 10px; + padding: 9px; + border-radius: var(--r-md); + background: var(--surface); + border: 1px solid var(--border); +} +.avatar { + display: grid; + place-items: center; + width: 34px; + height: 34px; + flex: 0 0 auto; + border-radius: 50%; + color: var(--on-accent); + font-size: 13px; + font-weight: 700; + background: linear-gradient(140deg, var(--accent), var(--accent-2)); } -/* Glass morphism effect */ -.glass { - background: rgba(255, 255, 255, 0.1); - backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.2); +/* ---------------------------------------------------------------------------- + Buttons & chips + -------------------------------------------------------------------------- */ +.icon-btn { + display: inline-grid; + place-items: center; + width: 38px; + height: 38px; + border-radius: 10px; + color: var(--text-dim); + background: var(--surface); + border: 1px solid var(--border); + transition: all 0.22s ease; + cursor: pointer; } +.icon-btn:hover { + color: var(--text); + border-color: var(--border-strong); + background: var(--surface-2); +} +.icon-btn:active { transform: scale(0.95); } -.glass-dark { - background: rgba(0, 0, 0, 0.1); - backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.1); +.chip { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 6px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 500; + color: var(--accent-ink); + background: var(--accent-soft); + border: 1px solid var(--accent-ring); +} +.chip__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent-2); + animation: live 2.6s ease-in-out infinite; +} +@keyframes live { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } } -/* Gradient text */ -.gradient-text { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; +.btn-accent { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--r-md); + color: var(--on-accent); + background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%); + border: none; + font-family: var(--font-body); + font-weight: 600; + font-size: 14px; + cursor: pointer; + box-shadow: 0 10px 26px -12px var(--accent-ring); + transition: transform 0.22s ease, box-shadow 0.22s ease, filter 0.22s ease; +} +.btn-accent:hover:not(:disabled) { + filter: brightness(1.08) saturate(1.04); + transform: translateY(-1px); + box-shadow: 0 16px 32px -12px var(--accent-ring); } +.btn-accent:active:not(:disabled) { transform: scale(0.97); } +.btn-accent:disabled { + cursor: not-allowed; + color: var(--text-faint); + background: var(--surface-3); + box-shadow: none; +} +.btn-round { border-radius: 999px; } -/* Custom focus styles */ -.focus-ring:focus { - outline: none; - box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.3); +.btn-soft { + border-radius: var(--r-md); + padding: 12px 18px; + font-weight: 600; + font-size: 14px; + color: var(--text); + background: var(--surface-2); + border: 1px solid var(--border); + transition: all 0.22s ease; + cursor: pointer; } +.btn-soft:hover:not(:disabled) { + border-color: var(--border-strong); + background: var(--surface-3); +} +.btn-soft:disabled { opacity: 0.5; cursor: not-allowed; } -/* Smooth transitions */ -.transition-all { - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +/* ---------------------------------------------------------------------------- + Suggestion cards + -------------------------------------------------------------------------- */ +.suggest-card { + position: relative; + overflow: hidden; + text-align: left; + padding: 18px; + border-radius: var(--r-lg); + background: var(--surface); + border: 1px solid var(--border); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), var(--shadow-sm); + cursor: pointer; + transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.28s ease, + border-color 0.28s ease; +} +.suggest-card::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background: radial-gradient(130% 80% at 50% -10%, var(--accent-soft), transparent 60%); + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; + z-index: 0; +} +.suggest-card > * { + position: relative; + z-index: 1; +} +.suggest-card:hover { + transform: translateY(-3px); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), var(--shadow-md); + border-color: var(--accent-ring); +} +.suggest-card:hover::after { opacity: 1; } +.suggest-card:hover .suggest-card__arrow { transform: translate(3px, -3px); color: var(--accent); } +.suggest-card:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--accent-ring); } +.suggest-card__icon { + display: grid; + place-items: center; + width: 42px; + height: 42px; + border-radius: 12px; + color: var(--accent-ink); + background: linear-gradient(140deg, var(--accent-soft), transparent); + border: 1px solid var(--border); +} +.suggest-card__arrow { + color: var(--text-faint); + transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1), color 0.28s ease; } -/* Hover effects */ -.hover-lift:hover { - transform: translateY(-2px); - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15); +/* ---------------------------------------------------------------------------- + Chat bubbles + -------------------------------------------------------------------------- */ +.bubble { + max-width: 100%; + padding: 12px 16px; + border-radius: var(--r-lg); + font-size: 15px; + line-height: 1.65; + white-space: pre-wrap; + word-break: break-word; +} +.bubble-user { + color: var(--on-accent); + background: var(--accent); + border-bottom-right-radius: 6px; + box-shadow: 0 10px 24px -16px var(--accent-ring); +} +.bubble-ai { + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-bottom-left-radius: 6px; + box-shadow: var(--shadow-sm); +} +.bubble-error { + color: var(--text); + background: var(--danger-soft); + border: 1px solid var(--danger); + border-bottom-left-radius: 6px; } -/* Typing indicator */ -.typing-indicator { - display: inline-block; +/* ---------------------------------------------------------------------------- + Composer + -------------------------------------------------------------------------- */ +.composer { position: relative; - width: 40px; - height: 20px; + border-radius: var(--r-lg); + background: var(--surface); + border: 1px solid var(--border); + box-shadow: var(--shadow-md); + transition: border-color 0.25s ease, box-shadow 0.25s ease; +} +.composer:focus-within { + border-color: var(--accent-ring); + box-shadow: 0 0 0 3px var(--accent-soft), var(--shadow-md); +} +.composer textarea { + width: 100%; + resize: none; + border: 0; + background: transparent; + outline: none; + color: var(--text); + font-family: var(--font-body); + font-size: 15px; + line-height: 1.6; } +.composer textarea::placeholder { color: var(--text-faint); } -.typing-indicator::before, -.typing-indicator::after { - content: ''; - position: absolute; - top: 0; - left: 0; +/* ---------------------------------------------------------------------------- + Typing indicator + -------------------------------------------------------------------------- */ +.shimmer { + background: linear-gradient(90deg, var(--text-faint) 0%, var(--text) 35%, var(--text-faint) 65%); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + animation: shimmer 2.6s linear infinite; +} +@keyframes shimmer { + to { background-position: -200% 0; } +} +.dot-3 span { + display: inline-block; width: 6px; height: 6px; + margin: 0 2px; border-radius: 50%; - background: currentColor; - animation: typing 1.4s infinite ease-in-out; + background: var(--accent-2); + animation: dotty 1.4s ease-in-out infinite; +} +.dot-3 span:nth-child(2) { animation-delay: 0.18s; } +.dot-3 span:nth-child(3) { animation-delay: 0.36s; } +@keyframes dotty { + 0%, 70%, 100% { transform: translateY(0); opacity: 0.4; } + 35% { transform: translateY(-6px); opacity: 1; } } -.typing-indicator::after { - left: 8px; - animation-delay: 0.2s; +/* ---------------------------------------------------------------------------- + Modal + form + -------------------------------------------------------------------------- */ +.modal-card { + position: relative; + border-radius: var(--r-xl); + background: var(--surface); + border: 1px solid var(--border); + box-shadow: var(--shadow-lg); +} +.field { + width: 100%; + padding: 12px 44px 12px 14px; + border-radius: var(--r-md); + background: var(--surface-2); + border: 1px solid var(--border); + color: var(--text); + font-family: var(--font-body); + font-size: 14px; + transition: border-color 0.22s ease, box-shadow 0.22s ease; +} +.field::placeholder { color: var(--text-faint); } +.field:focus { + outline: none; + border-color: var(--accent-ring); + box-shadow: 0 0 0 3px var(--accent-soft); +} +.info-panel { + border-radius: var(--r-md); + background: var(--surface-2); + border: 1px solid var(--border); } -.typing-indicator::before { - animation-delay: 0.4s; +/* ---------------------------------------------------------------------------- + Motion + -------------------------------------------------------------------------- */ +@keyframes rise { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} +@keyframes fade { + from { opacity: 0; } + to { opacity: 1; } } +.rise { animation: rise 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; } +.fade { animation: fade 0.5s ease both; } +.msg-in { animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both; } -@keyframes typing { - 0%, 60%, 100% { - transform: translateY(0); - } - 30% { - transform: translateY(-10px); +/* ---------------------------------------------------------------------------- + Scrollbar + -------------------------------------------------------------------------- */ +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; +} +::-webkit-scrollbar-thumb:hover { background: var(--text-faint); background-clip: padding-box; } + +/* ---------------------------------------------------------------------------- + Reduced motion + -------------------------------------------------------------------------- */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; } } diff --git a/client/index.html b/client/index.html index 0b03d94..04b361a 100644 --- a/client/index.html +++ b/client/index.html @@ -3,7 +3,18 @@ - + + + + + + + + + EchoAI diff --git a/client/src/App.tsx b/client/src/App.tsx index 4585284..d7ecfbe 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,79 +1,65 @@ /** * EchoAI Main Application Component * - * This is the main React component that orchestrates the entire EchoAI application. - * It manages chat state, API key management, theme switching, and error handling. - * - * Features: - * - Chat message management and display - * - Dark/light mode theme switching - * - API key management and validation - * - Error handling with retry mechanisms - * - Suggestion prompts for common use cases - * - Responsive design for all devices + * Orchestrates the workspace: a conversation sidebar, the chat workspace, API + * key management, theme switching and error handling. Conversations persist + * client-side via the useConversations store. * * @author EchoAI Contributors * @license Apache-2.0 */ import { Analytics } from '@vercel/analytics/react'; -import { useEffect, useReducer, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useMutation } from 'react-query'; import ChatBody from './components/ChatBody'; import ChatInput, { ChatInputRef, Message } from './components/ChatInput'; import Header from './components/Header'; import SettingsModal from './components/SettingsModal'; +import Sidebar from './components/Sidebar'; import { fetchResponse } from './utils/Api'; import { ApiKeyManager } from './utils/ApiKeyManager'; +import { useConversations } from './utils/useConversations'; -type AppState = { - chats: Message[]; - isLoading: boolean; - isRetrying: boolean; -}; - -type AppAction = - | { type: 'ADD_MESSAGE'; message: Message } - | { type: 'REMOVE_ERROR_MESSAGE'; errorId: string } - | { type: 'SET_LOADING'; isLoading: boolean } - | { type: 'SET_RETRYING'; isRetrying: boolean } - | { type: 'SET_CHATS'; chats: Message[] }; +const NAME_KEY = 'echoai_display_name'; -function appReducer(state: AppState, action: AppAction): AppState { - switch (action.type) { - case 'ADD_MESSAGE': - return { ...state, chats: [...state.chats, action.message] }; - case 'REMOVE_ERROR_MESSAGE': - return { ...state, chats: state.chats.filter(c => c.errorId !== action.errorId) }; - case 'SET_LOADING': - return { ...state, isLoading: action.isLoading }; - case 'SET_RETRYING': - return { ...state, isRetrying: action.isRetrying }; - case 'SET_CHATS': - return { ...state, chats: action.chats }; - default: - return state; - } -} +const newMessageId = (): string => + Date.now().toString() + Math.random().toString(36).substr(2, 9); function App() { - const [state, dispatch] = useReducer(appReducer, { - chats: [], - isLoading: false, - isRetrying: false, - }); + const { + conversations, + activeId, + activeChats, + selectConversation, + createConversation, + deleteConversation, + renameConversation, + addMessage, + removeErrorMessage, + } = useConversations(); + + const [isLoading, setIsLoading] = useState(false); + const [isRetrying, setIsRetrying] = useState(false); const [isDarkMode, setIsDarkMode] = useState(true); const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [userApiKey, setUserApiKey] = useState(''); + const [displayName, setDisplayName] = useState('You'); const chatInputRef = useRef(null); + // The conversation a pending request belongs to (so a reply lands in the + // right chat even if the user switches conversations mid-request). + const pendingConvId = useRef(''); - const { chats, isLoading, isRetrying } = state; - - // Load user API key on component mount useEffect(() => { - const storedApiKey = ApiKeyManager.getApiKey(); - setUserApiKey(storedApiKey || ''); + setUserApiKey(ApiKeyManager.getApiKey() || ''); + try { + const stored = localStorage.getItem(NAME_KEY); + if (stored) setDisplayName(stored); + } catch { + /* ignore */ + } }, []); const handleApiKeyChange = (newApiKey: string) => { @@ -85,90 +71,72 @@ function App() { setUserApiKey(newApiKey); }; - const handleRetryMessage = async (errorId: string) => { - // Find the user message that corresponds to this error - const errorIndex = chats.findIndex(chat => chat.errorId === errorId); - if (errorIndex === -1) return; - - // Find the user message before this error - let userMessageIndex = -1; - for (let i = errorIndex - 1; i >= 0; i--) { - if (chats[i].sender === 'user') { - userMessageIndex = i; - break; - } - } - - if (userMessageIndex === -1) return; - - const userMessage = chats[userMessageIndex]; - - // Remove the error message - dispatch({ type: 'REMOVE_ERROR_MESSAGE', errorId }); - - dispatch({ type: 'SET_RETRYING', isRetrying: true }); - dispatch({ type: 'SET_LOADING', isLoading: true }); - + const handleDisplayNameChange = (name: string) => { + const clean = name.trim() || 'You'; + setDisplayName(clean); try { - mutation.mutate([userMessage]); - } catch (error) { - console.error('Retry failed:', error); - } finally { - dispatch({ type: 'SET_RETRYING', isRetrying: false }); + localStorage.setItem(NAME_KEY, clean); + } catch { + /* ignore */ } }; - const handleUseOwnKey = () => { - setIsSettingsOpen(true); - }; - const mutation = useMutation({ - mutationFn: (messages: Message[]) => { - return fetchResponse(messages); - }, + mutationFn: (messages: Message[]) => fetchResponse(messages), onSuccess: (data: { message: string }) => { - dispatch({ - type: 'ADD_MESSAGE', - message: { - id: Date.now().toString() + Math.random().toString(36).substr(2, 9), - sender: 'ai', - message: data.message.replace(/^\n\n/, ''), - }, + addMessage(pendingConvId.current || activeId, { + id: newMessageId(), + sender: 'ai', + message: data.message.replace(/^\n\n/, ''), }); - dispatch({ type: 'SET_LOADING', isLoading: false }); }, onError: (error: unknown) => { console.error('Error fetching response:', error); const errorMessage = error instanceof Error ? error.message : 'Something went wrong. Please try again.'; - const errorId = `error_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - - dispatch({ - type: 'ADD_MESSAGE', - message: { - id: Date.now().toString() + Math.random().toString(36).substr(2, 9), - sender: 'ai', - message: errorMessage, - isError: true, - errorId: errorId, - }, + addMessage(pendingConvId.current || activeId, { + id: newMessageId(), + sender: 'ai', + message: errorMessage, + isError: true, + errorId: `error_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, }); - dispatch({ type: 'SET_LOADING', isLoading: false }); }, onSettled: () => { - // This runs after both success and error - dispatch({ type: 'SET_LOADING', isLoading: false }); + setIsLoading(false); + setIsRetrying(false); }, }); - const sendMessage = async (message: Message) => { - dispatch({ type: 'ADD_MESSAGE', message }); - dispatch({ type: 'SET_LOADING', isLoading: true }); + const sendMessage = (message: Message) => { + pendingConvId.current = activeId; + addMessage(activeId, message); + setIsLoading(true); mutation.mutate([message]); }; + const handleRetryMessage = (errorId: string) => { + const errorIndex = activeChats.findIndex(chat => chat.errorId === errorId); + if (errorIndex === -1) return; + + let userMessageIndex = -1; + for (let i = errorIndex - 1; i >= 0; i--) { + if (activeChats[i].sender === 'user') { + userMessageIndex = i; + break; + } + } + if (userMessageIndex === -1) return; + + const userMessage = activeChats[userMessageIndex]; + pendingConvId.current = activeId; + removeErrorMessage(activeId, errorId); + setIsRetrying(true); + setIsLoading(true); + mutation.mutate([userMessage]); + }; + const handleSuggestionClick = (suggestion: string) => { - // Map suggestions to better AI prompts const promptMap: { [key: string]: string } = { 'Explain quantum computing': 'Please explain quantum computing in simple terms, covering the basic concepts like qubits, superposition, and entanglement, and how it differs from classical computing.', @@ -179,73 +147,76 @@ function App() { 'Plan a vacation': 'Help me create a comprehensive vacation plan including popular destinations, must-visit attractions, recommended hotels, local cuisine, and travel tips.', }; - - const prompt = promptMap[suggestion] || suggestion; - chatInputRef.current?.setSuggestion(prompt); + chatInputRef.current?.setSuggestion(promptMap[suggestion] || suggestion); }; - const toggleDarkMode = () => { - setIsDarkMode(!isDarkMode); + const handleNewChat = () => { + createConversation(); + setIsSidebarOpen(false); }; - return ( -
- {/* Background Gradient Overlay */} -
+ const handleSelectConversation = (id: string) => { + selectConversation(id); + setIsSidebarOpen(false); + }; - {/* Background Pattern */} -
-
-
+ const toggleDarkMode = () => setIsDarkMode(prev => !prev); -
-
+
+ +
+ setIsSettingsOpen(true)} + isOpen={isSidebarOpen} + onClose={() => setIsSidebarOpen(false)} /> - {/* Main Chat Area */} -
-
-
+
+
setIsSettingsOpen(true)} + onOpenSidebar={() => setIsSidebarOpen(true)} + /> + +
+
setIsSettingsOpen(true)} isRetrying={isRetrying} />
-
+
- {/* Chat Input */} -
- +
+
+ +
- +
- {/* Settings Modal */} {isSettingsOpen && ( setIsSettingsOpen(false)} diff --git a/client/src/components/AiChat.tsx b/client/src/components/AiChat.tsx index 71a46a1..817d91a 100644 --- a/client/src/components/AiChat.tsx +++ b/client/src/components/AiChat.tsx @@ -1,62 +1,56 @@ -import { FC } from "react"; -import InlineErrorOptions from "./InlineErrorOptions"; +import { FC } from 'react'; +import InlineErrorOptions from './InlineErrorOptions'; +import Orb from './Orb'; interface AiChatProps { message: string; - isDarkMode: boolean; isError?: boolean; onRetry?: () => void; onUseOwnKey?: () => void; isRetrying?: boolean; } -const AiChat: FC = ({ - message, - isDarkMode, - isError = false, - onRetry, - onUseOwnKey, - isRetrying = false +const AiChat: FC = ({ + message, + isError = false, + onRetry, + onUseOwnKey, + isRetrying = false, }) => { return ( -
-
-
- {isError ? ( - - +
+
+ {isError ? ( +
+ + - ) : ( - - - - )} -
-
-

- {message} -

- - {/* Show error options if this is an error message */} +
+ ) : ( + + )} + +
+ {message} {isError && onRetry && onUseOwnKey && ( - + )}
diff --git a/client/src/components/ChatBody.tsx b/client/src/components/ChatBody.tsx index cae1f6b..b395449 100644 --- a/client/src/components/ChatBody.tsx +++ b/client/src/components/ChatBody.tsx @@ -1,7 +1,8 @@ import autoAnimate from '@formkit/auto-animate'; -import { FC, useEffect, useRef } from 'react'; +import { FC, ReactNode, useEffect, useRef } from 'react'; import AiChat from './AiChat'; import { Message } from './ChatInput'; +import Orb from './Orb'; import TypingIndicator from './TypingIndicator'; import UserChat from './UserChat'; @@ -15,9 +16,64 @@ interface ChatBodyProps { isRetrying?: boolean; } +/** Prompt presets — `key` matches App's promptMap; the rest is presentation. */ +const SUGGESTIONS: { key: string; title: string; hint: string; icon: ReactNode }[] = [ + { + key: 'Explain quantum computing', + title: 'Explain a concept', + hint: 'Quantum computing, made simple', + icon: ( + + ), + }, + { + key: 'Write a creative story', + title: 'Write something', + hint: 'A short, original story', + icon: ( + + ), + }, + { + key: 'Help with coding', + title: 'Help me build', + hint: 'Debug & best practices', + icon: ( + + ), + }, + { + key: 'Plan a vacation', + title: 'Plan a trip', + hint: 'A full itinerary, sorted', + icon: ( + + ), + }, +]; + const ChatBody: FC = ({ chats, - isDarkMode, isLoading = false, onSuggestionClick, onRetryMessage, @@ -27,27 +83,26 @@ const ChatBody: FC = ({ const parent = useRef(null); const bottomRef = useRef(null); - //for animation useEffect(() => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions parent.current && autoAnimate(parent.current); }, [parent]); - // for scrolling useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [chats, isLoading]); + const isEmpty = chats.length === 0 && !isLoading; + return ( -
+
{chats.map((chat: Message) => { return chat.sender === 'user' ? ( - + ) : ( onRetryMessage?.(chat.errorId!) : undefined @@ -58,71 +113,75 @@ const ChatBody: FC = ({ ); })} - {isLoading && } + {isLoading && } - {chats.length === 0 && !isLoading && ( -
-
-
- - - -
+ {isEmpty && ( +
+
+
+

- Welcome to EchoAI + How can I help you today?

-

- Your intelligent AI assistant is ready to help. Ask me anything! + +

+ Ask anything, explore an idea, or start with one of these.

-
- {[ - 'Explain quantum computing', - 'Write a creative story', - 'Help with coding', - 'Plan a vacation', - ].map((suggestion, index) => ( -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onSuggestionClick?.(suggestion); - } - }} - onClick={() => onSuggestionClick?.(suggestion)} - className={`p-3 sm:p-4 rounded-xl border transition-all duration-300 hover:scale-105 cursor-pointer hover-lift ${ - isDarkMode - ? 'bg-white/5 border-white/10 hover:bg-white/10' - : 'bg-white/50 border-gray-200 hover:bg-white/80' - }`} - style={{ animationDelay: `${index * 0.1}s` }} + +
+ {SUGGESTIONS.map((s, index) => ( +
+ ))}
)} -
+ +
); }; diff --git a/client/src/components/ChatInput.tsx b/client/src/components/ChatInput.tsx index b18caf0..dfe530a 100644 --- a/client/src/components/ChatInput.tsx +++ b/client/src/components/ChatInput.tsx @@ -18,23 +18,22 @@ interface ChatInputProps { isDarkMode: boolean; } +const MAX = 2000; + const ChatInput = React.forwardRef( - ({ sendMessage, isLoading, isDarkMode }, ref) => { + ({ sendMessage, isLoading }, ref) => { const [value, setValue] = useState(''); const textareaRef = useRef(null); React.useImperativeHandle(ref, () => ({ setSuggestion: (text: string) => { setValue(text); - if (textareaRef.current) { - textareaRef.current.focus(); - } + textareaRef.current?.focus(); }, })); const handleSubmit = () => { if (value.trim() === '' || isLoading) return; - sendMessage({ id: Date.now().toString() + Math.random().toString(36).substr(2, 9), sender: 'user', @@ -58,78 +57,56 @@ const ChatInput = React.forwardRef( } }, [value]); + const canSend = value.trim() !== '' && !isLoading; + return ( -
-
-
-