Đỗ Hải Nam commited on
Commit
471f166
·
1 Parent(s): ba5110e

feat(frontend): interactive chat UI with math rendering

Browse files
frontend/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
frontend/README.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # React + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9
+
10
+ ## React Compiler
11
+
12
+ The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
13
+
14
+ ## Expanding the ESLint configuration
15
+
16
+ If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
frontend/eslint.config.js ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import { defineConfig, globalIgnores } from 'eslint/config'
6
+
7
+ export default defineConfig([
8
+ globalIgnores(['dist']),
9
+ {
10
+ files: ['**/*.{js,jsx}'],
11
+ extends: [
12
+ js.configs.recommended,
13
+ reactHooks.configs.flat.recommended,
14
+ reactRefresh.configs.vite,
15
+ ],
16
+ languageOptions: {
17
+ ecmaVersion: 2020,
18
+ globals: globals.browser,
19
+ parserOptions: {
20
+ ecmaVersion: 'latest',
21
+ ecmaFeatures: { jsx: true },
22
+ sourceType: 'module',
23
+ },
24
+ },
25
+ rules: {
26
+ 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
27
+ },
28
+ },
29
+ ])
frontend/index.html ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <link rel="icon" type="image/svg+xml" href="/pochi.jpeg" />
7
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
+ <title>Pochi</title>
9
+ <!-- Prevent FOUC: Apply theme class before CSS loads -->
10
+ <script>
11
+ (function () {
12
+ try {
13
+ var darkMode = localStorage.getItem('darkMode');
14
+ if (darkMode === 'true' || (darkMode === null && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
15
+ document.documentElement.classList.add('dark');
16
+ } else {
17
+ document.documentElement.classList.add('light');
18
+ }
19
+ } catch (e) { }
20
+ })();
21
+ </script>
22
+ </head>
23
+
24
+ <body>
25
+ <div id="root"></div>
26
+ <script type="module" src="/src/main.jsx"></script>
27
+ </body>
28
+
29
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "html2canvas": "^1.4.1",
14
+ "jspdf": "^3.0.4",
15
+ "katex": "^0.16.27",
16
+ "lucide-react": "^0.562.0",
17
+ "react": "^19.2.0",
18
+ "react-dom": "^19.2.0",
19
+ "react-dropzone": "^14.3.8",
20
+ "react-joyride": "^2.9.3",
21
+ "react-katex": "^3.1.0",
22
+ "react-markdown": "^10.1.0",
23
+ "rehype-katex": "^7.0.1",
24
+ "rehype-mathjax": "^7.1.0",
25
+ "remark-gfm": "^4.0.1",
26
+ "remark-math": "^6.0.0"
27
+ },
28
+ "devDependencies": {
29
+ "@eslint/js": "^9.39.1",
30
+ "@tailwindcss/typography": "^0.5.19",
31
+ "@tailwindcss/vite": "^4.1.18",
32
+ "@types/react": "^19.2.5",
33
+ "@types/react-dom": "^19.2.3",
34
+ "@vitejs/plugin-react": "^5.1.1",
35
+ "autoprefixer": "^10.4.23",
36
+ "eslint": "^9.39.1",
37
+ "eslint-plugin-react-hooks": "^7.0.1",
38
+ "eslint-plugin-react-refresh": "^0.4.24",
39
+ "globals": "^16.5.0",
40
+ "postcss": "^8.5.6",
41
+ "tailwindcss": "^4.1.18",
42
+ "vite": "npm:rolldown-vite@7.2.5"
43
+ },
44
+ "overrides": {
45
+ "vite": "npm:rolldown-vite@7.2.5"
46
+ }
47
+ }
frontend/public/calculus-icon.png ADDED

Git LFS Details

  • SHA256: 6f09aa4f9644b62b390b136d486be5b0632ae90bf328b3429b54cdef27cabc10
  • Pointer size: 130 Bytes
  • Size of remote file: 31.4 kB
frontend/public/electrocardiogram-svgrepo-com.svg ADDED
frontend/public/hnam.jpeg ADDED

Git LFS Details

  • SHA256: 492364b6a2e55004466e89321759572be9eaf54d5d9fab27c3026a62e560d94c
  • Pointer size: 131 Bytes
  • Size of remote file: 676 kB
frontend/public/pochi.jpeg ADDED

Git LFS Details

  • SHA256: 6dd24bcf4dbaecb7bc4baa758a3bd0167a3f8bcd7c9bfef602532848a88aa017
  • Pointer size: 131 Bytes
  • Size of remote file: 789 kB
frontend/src/App.css ADDED
The diff for this file is too large to render. See raw diff
 
frontend/src/App.jsx ADDED
@@ -0,0 +1,806 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useLayoutEffect, useRef } from 'react'
2
+ import Header from './components/Header'
3
+ import Sidebar from './components/Sidebar'
4
+ import MessageList from './components/MessageList'
5
+ import ChatInput from './components/ChatInput'
6
+ import { SearchModal, ImageViewer, SettingsModal } from './components/Modals'
7
+ import { Menu, MoreHorizontal } from 'lucide-react'
8
+ import './App.css'
9
+ import GuideTour from './components/GuideTour'
10
+
11
+ const API_BASE = 'http://localhost:7860/api'
12
+
13
+ function App() {
14
+ // --- State Management ---
15
+ const [darkMode, setDarkMode] = useState(() => {
16
+ if (typeof window !== 'undefined') {
17
+ const saved = localStorage.getItem('darkMode')
18
+ if (saved !== null) return saved === 'true'
19
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
20
+ }
21
+ return false
22
+ })
23
+
24
+ const [conversations, setConversations] = useState([])
25
+ const [currentConversation, setCurrentConversation] = useState(() => {
26
+ return localStorage.getItem('lastConversationId') || null
27
+ })
28
+ const [messages, setMessages] = useState([])
29
+ const [isLoading, setIsLoading] = useState(false)
30
+ const [isInitializing, setIsInitializing] = useState(true) // Prevents flash on refresh
31
+
32
+ // User Profile State
33
+ const [userProfile, setUserProfile] = useState(() => {
34
+ const saved = localStorage.getItem('user_profile')
35
+ return saved ? JSON.parse(saved) : {
36
+ name: 'Guest',
37
+ email: 'guest@example.com',
38
+ avatar: '/hnam.jpeg'
39
+ }
40
+ })
41
+
42
+ const handleUpdateProfile = (newProfile) => {
43
+ const updated = { ...userProfile, ...newProfile }
44
+ setUserProfile(updated)
45
+ localStorage.setItem('user_profile', JSON.stringify(updated))
46
+ }
47
+
48
+ // UI State
49
+ const [sidebarOpen, setSidebarOpen] = useState(() => {
50
+ const saved = localStorage.getItem('sidebarOpen')
51
+ return saved !== null ? saved === 'true' : true
52
+ })
53
+ const [showSearch, setShowSearch] = useState(() => {
54
+ return sessionStorage.getItem('showSearch') === 'true'
55
+ })
56
+ const [tourVersion, setTourVersion] = useState(0)
57
+ const [showTour, setShowTour] = useState(() => {
58
+ const hasSeenTour = localStorage.getItem('hasSeenTour')
59
+ const savedIndex = localStorage.getItem('tourStepIndex')
60
+ // Auto-show if never seen or if interrupted (index exists and is not -1)
61
+ return !hasSeenTour || (savedIndex !== null && savedIndex !== '-1')
62
+ })
63
+ const [showSettings, setShowSettings] = useState(() => {
64
+ return sessionStorage.getItem('showSettings') === 'true'
65
+ })
66
+ const [settingsTab, setSettingsTab] = useState(() => {
67
+ return localStorage.getItem('settingsTab') || 'general'
68
+ })
69
+ const [viewerData, setViewerData] = useState(() => {
70
+ try {
71
+ const saved = sessionStorage.getItem('viewerData')
72
+ return saved ? JSON.parse(saved) : null
73
+ } catch (e) {
74
+ console.error('Failed to parse viewerData:', e)
75
+ return null
76
+ }
77
+ }) // { images: [], index: 0 }
78
+ const [memoryStatus, setMemoryStatus] = useState(null) // For future memory blocking features
79
+ const [showPinLimitToast, setShowPinLimitToast] = useState(false)
80
+
81
+ const [isMobile, setIsMobile] = useState(window.innerWidth < 768)
82
+ const [showRenameModal, setShowRenameModal] = useState(() => {
83
+ return sessionStorage.getItem('showRenameModal') === 'true'
84
+ })
85
+ const [modalTempTitle, setModalTempTitle] = useState(() => {
86
+ return sessionStorage.getItem('modalTempTitle') || ''
87
+ })
88
+ const appRef = useRef(null)
89
+ const isCreatingSessionRef = useRef(false)
90
+ const isInitialMountRef = useRef(true) // Track if this is the first mount after refresh
91
+
92
+ // --- Effects ---
93
+
94
+ // Note: Removed useLayoutEffect scroll reset - it caused more issues than it solved.
95
+ // Scroll persistence is now handled in MessageList.
96
+
97
+
98
+ // Dark Mode - Apply synchronously before paint to prevent flickering
99
+ useLayoutEffect(() => {
100
+ document.documentElement.classList.remove('light', 'dark')
101
+ document.documentElement.classList.add(darkMode ? 'dark' : 'light')
102
+ localStorage.setItem('darkMode', darkMode)
103
+ }, [darkMode])
104
+
105
+ useEffect(() => {
106
+ localStorage.setItem('sidebarOpen', sidebarOpen)
107
+ }, [sidebarOpen])
108
+
109
+ useEffect(() => {
110
+ sessionStorage.setItem('showSearch', showSearch)
111
+ }, [showSearch])
112
+
113
+ useEffect(() => {
114
+ sessionStorage.setItem('showSettings', showSettings)
115
+ }, [showSettings])
116
+
117
+ useEffect(() => {
118
+ localStorage.setItem('settingsTab', settingsTab)
119
+ }, [settingsTab])
120
+
121
+ useEffect(() => {
122
+ sessionStorage.setItem('showRenameModal', showRenameModal)
123
+ sessionStorage.setItem('modalTempTitle', modalTempTitle)
124
+ }, [showRenameModal, modalTempTitle])
125
+
126
+ useEffect(() => {
127
+ if (viewerData) {
128
+ sessionStorage.setItem('viewerData', JSON.stringify(viewerData))
129
+ } else {
130
+ sessionStorage.removeItem('viewerData')
131
+ }
132
+ }, [viewerData])
133
+
134
+ // Scroll Restoration & Event Listeners
135
+ useEffect(() => {
136
+ // Disable native scroll restoration to prevent jump on refresh
137
+ if ('scrollRestoration' in window.history) {
138
+ window.history.scrollRestoration = 'manual'
139
+ }
140
+
141
+ const handleResize = () => setIsMobile(window.innerWidth < 768)
142
+ const handleKeyDown = (e) => {
143
+ // Cmd+F or Ctrl+F for Search
144
+ if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
145
+ e.preventDefault()
146
+ setShowSearch(true)
147
+ }
148
+
149
+ // Cmd+E or Ctrl+E for New Chat
150
+ if ((e.metaKey || e.ctrlKey) && e.key === 'e') {
151
+ e.preventDefault()
152
+ createConversation()
153
+ }
154
+
155
+ // Cmd+X or Ctrl+X for General Settings
156
+ if ((e.metaKey || e.ctrlKey) && e.key === 'x') {
157
+ e.preventDefault()
158
+ setSettingsTab('general')
159
+ setShowSettings(true)
160
+ }
161
+
162
+ // Cmd+I or Ctrl+I for Account Settings
163
+ if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
164
+ e.preventDefault()
165
+ setSettingsTab('account')
166
+ setShowSettings(true)
167
+ }
168
+
169
+ // Cmd+K or Ctrl+K for Dark Mode Toggle
170
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
171
+ e.preventDefault()
172
+ setDarkMode(prev => !prev)
173
+ }
174
+ }
175
+
176
+ window.addEventListener('resize', handleResize)
177
+ window.addEventListener('keydown', handleKeyDown)
178
+
179
+ return () => {
180
+ window.removeEventListener('resize', handleResize)
181
+ window.removeEventListener('keydown', handleKeyDown)
182
+ }
183
+ }, [])
184
+
185
+ // Load Conversations on Mount
186
+ useEffect(() => {
187
+ const initConversations = async () => {
188
+ try {
189
+ const res = await fetch(`${API_BASE}/conversations`)
190
+ const data = await res.json()
191
+
192
+ // Merge persisted metadata (Pin/Archive)
193
+ const persistedMetadata = JSON.parse(localStorage.getItem('chat_metadata') || '{}')
194
+ const mergedData = data.map(conv => ({
195
+ ...conv,
196
+ isPinned: persistedMetadata[conv.id]?.isPinned || false,
197
+ isArchived: persistedMetadata[conv.id]?.isArchived || false
198
+ }))
199
+
200
+ setConversations(mergedData)
201
+
202
+ const savedId = localStorage.getItem('lastConversationId')
203
+ if (savedId) {
204
+ const exists = data.find(c => c.id === savedId)
205
+ if (exists) {
206
+ // Load messages for saved conversation before showing UI
207
+ await loadMessagesSync(savedId)
208
+ } else {
209
+ localStorage.removeItem('lastConversationId')
210
+ setCurrentConversation(null)
211
+ setMessages([])
212
+ }
213
+ }
214
+ } catch (error) {
215
+ console.error('Failed to fetch conversations:', error)
216
+ } finally {
217
+ // Always mark initialization as complete
218
+ setIsInitializing(false)
219
+ }
220
+ }
221
+ initConversations()
222
+ }, [])
223
+
224
+ // Mark initial mount as complete after first render cycle of the REAL UI
225
+ useEffect(() => {
226
+ if (!isInitializing) {
227
+ const timer = setTimeout(() => {
228
+ isInitialMountRef.current = false
229
+ }, 300) // Slightly longer to be safe
230
+ return () => clearTimeout(timer)
231
+ }
232
+ }, [isInitializing])
233
+
234
+ // Load Messages when Conversation Changes
235
+ useEffect(() => {
236
+ if (currentConversation) {
237
+ if (isCreatingSessionRef.current) {
238
+ isCreatingSessionRef.current = false
239
+ return
240
+ }
241
+ loadMessages(currentConversation)
242
+ if (currentConversation === 'null') {
243
+ localStorage.setItem('lastConversationId', 'null')
244
+ } else {
245
+ localStorage.setItem('lastConversationId', currentConversation)
246
+ }
247
+ } else {
248
+ setMessages([])
249
+ }
250
+ }, [currentConversation])
251
+
252
+ // Auto-refresh for pending messages (e.g., if user returns to a generating session)
253
+ useEffect(() => {
254
+ if (!currentConversation || isLoading || messages.length === 0) return
255
+
256
+ const lastMsg = messages[messages.length - 1]
257
+ const isPending = lastMsg.role === 'assistant' && !lastMsg.content
258
+
259
+ if (isPending) {
260
+ const pollInterval = setInterval(() => {
261
+ loadMessagesSync(currentConversation)
262
+ }, 3000) // Poll every 3s
263
+
264
+ return () => clearInterval(pollInterval)
265
+ }
266
+ }, [messages, currentConversation, isLoading])
267
+
268
+ // Simulation Engine for "Resume Streaming" experience
269
+ useEffect(() => {
270
+ if (messages.length === 0 || isLoading) return
271
+
272
+ const lastIdx = messages.length - 1
273
+ const lastMsg = messages[lastIdx]
274
+
275
+ if (lastMsg.isSimulating && lastMsg._fullContent) {
276
+ const simulationTimer = setTimeout(() => {
277
+ setMessages(prev => {
278
+ const newMessages = [...prev]
279
+ const msg = newMessages[lastIdx]
280
+ if (!msg || !msg.isSimulating) return prev
281
+
282
+ const currentLen = msg.content.length
283
+ const fullContent = msg._fullContent
284
+
285
+ // Batch size for simulation (approx 24 chars for that "super fast" feel)
286
+ const batchSize = 24
287
+ const nextContent = fullContent.substring(0, currentLen + batchSize)
288
+
289
+ msg.content = nextContent
290
+
291
+ if (nextContent.length >= fullContent.length) {
292
+ msg.isStreaming = false
293
+ msg.isSimulating = false
294
+ delete msg._fullContent
295
+ }
296
+
297
+ return newMessages
298
+ })
299
+ }, 5) // Very fast update for simulation
300
+
301
+ return () => clearTimeout(simulationTimer)
302
+ }
303
+ }, [messages, isLoading])
304
+
305
+ // --- Actions ---
306
+
307
+ const loadMessages = async (conversationId) => {
308
+ try {
309
+ const res = await fetch(`${API_BASE}/conversations/${conversationId}/messages`)
310
+ const data = await res.json()
311
+ setMessages(parseMessagesData(data))
312
+ } catch (error) {
313
+ console.error('Failed to load messages:', error)
314
+ }
315
+ }
316
+
317
+ // Sync version for initial load (doesn't trigger effects that cause scroll)
318
+ const loadMessagesSync = async (conversationId) => {
319
+ try {
320
+ const res = await fetch(`${API_BASE}/conversations/${conversationId}/messages`)
321
+ const data = await res.json()
322
+ setMessages(parseMessagesData(data))
323
+ } catch (error) {
324
+ console.error('Failed to load messages:', error)
325
+ }
326
+ }
327
+
328
+ // Helper to parse messages data
329
+ const parseMessagesData = (data) => {
330
+ const TYPING_SPEED = 0.25 // chars/ms (approx 250 chars/sec)
331
+ const now = new Date().getTime()
332
+
333
+ return data.map((m, idx) => {
334
+ let images = []
335
+ if (m.image_data) {
336
+ try {
337
+ const parsed = JSON.parse(m.image_data)
338
+ if (Array.isArray(parsed)) {
339
+ images = parsed.map(b64 => `data:image/jpeg;base64,${b64}`)
340
+ } else {
341
+ images = [`data:image/jpeg;base64,${m.image_data}`]
342
+ }
343
+ } catch (e) {
344
+ images = [`data:image/jpeg;base64,${m.image_data}`]
345
+ }
346
+ }
347
+
348
+ const createdAt = new Date(m.created_at).getTime()
349
+ const elapsed = now - createdAt
350
+ const fullContent = m.content || ''
351
+
352
+ // Only simulate for the VERY LAST message if it's an assistant message
353
+ const isLastMessage = idx === data.length - 1
354
+ const shouldSimulate = isLastMessage && m.role === 'assistant' && fullContent.length > 0
355
+
356
+ if (shouldSimulate) {
357
+ const expectedVisibleLength = Math.floor(elapsed * TYPING_SPEED)
358
+ if (expectedVisibleLength < fullContent.length) {
359
+ return {
360
+ role: m.role,
361
+ content: fullContent.substring(0, Math.max(0, expectedVisibleLength)),
362
+ _fullContent: fullContent, // Hidden property for simulation
363
+ images: images,
364
+ isStreaming: true,
365
+ isSimulating: true // Flag for simulation
366
+ }
367
+ }
368
+ }
369
+
370
+ return {
371
+ role: m.role,
372
+ content: fullContent,
373
+ images: images,
374
+ // If assistant message is empty, it means it's still being generated in background
375
+ isStreaming: m.role === 'assistant' && !fullContent
376
+ }
377
+ })
378
+ }
379
+
380
+ const createConversation = () => {
381
+ setCurrentConversation(null)
382
+ setMessages([])
383
+ localStorage.setItem('lastConversationId', 'null') // Persist current null state to prevent jump on refresh
384
+ if (window.innerWidth < 768) setSidebarOpen(false)
385
+ }
386
+
387
+ const deleteConversation = async (id) => {
388
+ try {
389
+ await fetch(`${API_BASE}/conversations/${id}`, { method: 'DELETE' })
390
+ const remaining = conversations.filter(c => c.id !== id)
391
+ setConversations(remaining)
392
+
393
+ if (currentConversation === id) {
394
+ setCurrentConversation(null)
395
+ setMessages([])
396
+ localStorage.removeItem('lastConversationId')
397
+ }
398
+
399
+ // Cleanup metadata
400
+ const metadata = JSON.parse(localStorage.getItem('chat_metadata') || '{}')
401
+ if (metadata[id]) {
402
+ delete metadata[id]
403
+ localStorage.setItem('chat_metadata', JSON.stringify(metadata))
404
+ }
405
+
406
+ // Cleanup message expansion states
407
+ Object.keys(localStorage).forEach(key => {
408
+ if (key.startsWith(`expand_${id}`)) {
409
+ localStorage.removeItem(key)
410
+ }
411
+ })
412
+ } catch (error) {
413
+ console.error('Failed to delete conversation:', error)
414
+ }
415
+ }
416
+
417
+ const renameConversation = async (id, newTitle) => {
418
+ // Optimistic UI update
419
+ setConversations(prev => prev.map(c =>
420
+ c.id === id ? { ...c, title: newTitle } : c
421
+ ))
422
+
423
+ try {
424
+ const res = await fetch(`${API_BASE}/conversations/${id}`, {
425
+ method: 'PATCH',
426
+ headers: { 'Content-Type': 'application/json' },
427
+ body: JSON.stringify({ title: newTitle })
428
+ })
429
+ if (!res.ok) throw new Error('Failed to rename on server')
430
+ } catch (error) {
431
+ console.error('Failed to rename conversation:', error)
432
+ // Revert on error? (Optional, maybe just notify)
433
+ }
434
+ }
435
+
436
+ const handleSaveRename = () => {
437
+ if (modalTempTitle.trim() && currentConversation) {
438
+ renameConversation(currentConversation, modalTempTitle)
439
+ }
440
+ setShowRenameModal(false)
441
+ }
442
+
443
+ const togglePin = (id) => {
444
+ setConversations(prev => {
445
+ const isCurrentlyPinned = prev.find(c => c.id === id)?.isPinned
446
+ const currentPinnedCount = prev.filter(c => c.isPinned).length
447
+
448
+ // If we are pinning (not unpinning) and already at 5, stop and warn.
449
+ if (!isCurrentlyPinned && currentPinnedCount >= 5) {
450
+ setShowPinLimitToast(true)
451
+ setTimeout(() => setShowPinLimitToast(false), 3000)
452
+ return prev
453
+ }
454
+
455
+ const updated = prev.map(c => {
456
+ if (c.id === id) {
457
+ const newPinned = !c.isPinned
458
+ return { ...c, isPinned: newPinned, isArchived: newPinned ? false : c.isArchived }
459
+ }
460
+ return c
461
+ })
462
+ // Persist metadata
463
+ const metadata = JSON.parse(localStorage.getItem('chat_metadata') || '{}')
464
+ const conv = updated.find(c => c.id === id)
465
+ metadata[id] = { ...metadata[id], isPinned: conv.isPinned, isArchived: conv.isArchived }
466
+ localStorage.setItem('chat_metadata', JSON.stringify(metadata))
467
+ return updated
468
+ })
469
+ }
470
+
471
+ const toggleArchive = (id) => {
472
+ setConversations(prev => {
473
+ const updated = prev.map(c => {
474
+ if (c.id === id) {
475
+ const newArchived = !c.isArchived
476
+ return { ...c, isArchived: newArchived, isPinned: newArchived ? false : c.isPinned }
477
+ }
478
+ return c
479
+ })
480
+ // If the current conversation is archived, deselect it
481
+ if (currentConversation === id) {
482
+ const isArchiving = !prev.find(c => c.id === id)?.isArchived
483
+ if (isArchiving) {
484
+ setCurrentConversation(null)
485
+ setMessages([])
486
+ }
487
+ }
488
+ // Persist metadata
489
+ const metadata = JSON.parse(localStorage.getItem('chat_metadata') || '{}')
490
+ const conv = updated.find(c => c.id === id)
491
+ metadata[id] = { ...metadata[id], isPinned: conv.isPinned, isArchived: conv.isArchived }
492
+ localStorage.setItem('chat_metadata', JSON.stringify(metadata))
493
+ return updated
494
+ })
495
+ }
496
+
497
+ const sendMessage = async (text, uploadedImages) => {
498
+ const userMessage = text.trim()
499
+ const imagePreviews = uploadedImages.map(img => img.preview)
500
+
501
+ // Optimistic UI update
502
+ setMessages(prev => [...prev, {
503
+ role: 'user',
504
+ content: userMessage,
505
+ images: imagePreviews
506
+ }])
507
+
508
+ setIsLoading(true)
509
+
510
+ const formData = new FormData()
511
+ formData.append('message', userMessage)
512
+ if (currentConversation) formData.append('session_id', currentConversation)
513
+ uploadedImages.forEach(img => {
514
+ formData.append('images', img.file)
515
+ })
516
+
517
+ try {
518
+ const res = await fetch(`${API_BASE}/chat`, { method: 'POST', body: formData })
519
+ const sessionId = res.headers.get('X-Session-Id')
520
+
521
+ // Handle New Session
522
+ if (sessionId && !currentConversation) {
523
+ isCreatingSessionRef.current = true
524
+ setCurrentConversation(sessionId)
525
+ localStorage.setItem('lastConversationId', sessionId)
526
+ setConversations(prev => {
527
+ if (prev.find(c => c.id === sessionId)) return prev
528
+ return [{ id: sessionId, title: userMessage.slice(0, 50), created_at: new Date().toISOString() }, ...prev]
529
+ })
530
+ }
531
+
532
+ // Stream Response
533
+ const reader = res.body.getReader()
534
+ const decoder = new TextDecoder()
535
+ let assistantMessage = ''
536
+
537
+ setMessages(prev => [...prev, { role: 'assistant', content: '', isStreaming: true }])
538
+
539
+ while (true) {
540
+ const { done, value } = await reader.read()
541
+ if (done) break
542
+
543
+ const chunk = decoder.decode(value, { stream: true })
544
+ const lines = chunk.split('\n').filter(l => l.trim().length > 0)
545
+ let batchTokens = 0
546
+ const BATCH_SIZE = 8 // Update UI every 8 tokens if they arrive at once
547
+
548
+ for (let i = 0; i < lines.length; i++) {
549
+ const line = lines[i]
550
+ if (line.startsWith('data: ')) {
551
+ const data = line.slice(6)
552
+ if (data === '[DONE]') break
553
+
554
+ let parsed = null
555
+ try { parsed = JSON.parse(data) }
556
+ catch { parsed = data } // Legacy fallback
557
+
558
+ if (typeof parsed === 'object' && parsed !== null && parsed.type) {
559
+ if (parsed.type === 'token') {
560
+ assistantMessage += parsed.content
561
+ batchTokens++
562
+ } else if (parsed.type === 'status') {
563
+ setMessages(prev => {
564
+ const newMessages = [...prev]
565
+ if (newMessages.length > 0) newMessages[newMessages.length - 1].status = parsed.status
566
+ return newMessages
567
+ })
568
+ } else if (parsed.type === 'done') {
569
+ break;
570
+ }
571
+ } else if (typeof parsed === 'string') {
572
+ assistantMessage += parsed
573
+ batchTokens++
574
+ }
575
+
576
+ // Update UI either on batch size or end of chunk lines
577
+ if (batchTokens >= BATCH_SIZE || i === lines.length - 1) {
578
+ setMessages(prev => {
579
+ const newMessages = [...prev]
580
+ const lastMsg = newMessages[newMessages.length - 1]
581
+ if (lastMsg) lastMsg.content = assistantMessage
582
+ return newMessages
583
+ })
584
+ batchTokens = 0
585
+
586
+ // A very tiny delay between batches to keep the main thread breathing
587
+ // and maintain the typewriter feel
588
+ await new Promise(resolve => setTimeout(resolve, 2))
589
+ }
590
+ }
591
+ }
592
+ }
593
+
594
+ // Finish Streaming
595
+ setMessages(prev => {
596
+ const newMessages = [...prev]
597
+ if (newMessages.length > 0) {
598
+ newMessages[newMessages.length - 1].isStreaming = false
599
+ newMessages[newMessages.length - 1].status = null // Clear any persistent "Thinking..." status
600
+ }
601
+ return newMessages
602
+ })
603
+
604
+ } catch (error) {
605
+ console.error('Failed to send message:', error)
606
+ setMessages(prev => {
607
+ const newMessages = [...prev]
608
+ // If the last message was the streaming one, mark it as finished/error
609
+ if (newMessages.length > 0 && newMessages[newMessages.length - 1].role === 'assistant') {
610
+ newMessages[newMessages.length - 1].isStreaming = false
611
+ newMessages[newMessages.length - 1].status = null
612
+ }
613
+ return [...newMessages, { role: 'assistant', content: 'Xin lỗi, đã có lỗi xảy ra.' }]
614
+ })
615
+ } finally {
616
+ setIsLoading(false)
617
+ }
618
+ }
619
+
620
+ return (
621
+ <div className="app-container" ref={appRef}>
622
+ {isInitializing ? (
623
+ // Only show loading if viewer is NOT active
624
+ !viewerData && (
625
+ <div className="flex items-center justify-center h-screen w-screen bg-bg-primary">
626
+ <div className="animate-spin w-8 h-8 border-4 border-primary border-t-transparent rounded-full"></div>
627
+ </div>
628
+ )
629
+ ) : (
630
+ <>
631
+ {showTour && (
632
+ <GuideTour
633
+ darkMode={darkMode}
634
+ tourVersion={tourVersion}
635
+ onTourEnd={() => {
636
+ setTourVersion(0)
637
+ setShowTour(false)
638
+ }}
639
+ />
640
+ )}
641
+ <Sidebar
642
+ isOpen={sidebarOpen}
643
+ toggleSidebar={() => setSidebarOpen(!sidebarOpen)}
644
+ conversations={[...conversations].sort((a, b) => {
645
+ if (a.isPinned && !b.isPinned) return -1
646
+ if (!a.isPinned && b.isPinned) return 1
647
+
648
+ const dateA = a.created_at ? new Date(a.created_at).getTime() : 0
649
+ const dateB = b.created_at ? new Date(b.created_at).getTime() : 0
650
+ return dateB - dateA
651
+ })}
652
+ currentConversationId={currentConversation}
653
+ onSelectConversation={(id) => {
654
+ setCurrentConversation(id)
655
+ if (window.innerWidth < 768) setSidebarOpen(false)
656
+ }}
657
+ onNewChat={createConversation}
658
+ onDeleteConversation={deleteConversation}
659
+ onRenameConversation={renameConversation}
660
+ onTogglePin={togglePin}
661
+ onToggleArchive={toggleArchive}
662
+ onSearchClick={() => setShowSearch(true)}
663
+ onSettingsClick={(tab = 'general') => {
664
+ setSettingsTab(tab)
665
+ setShowSettings(true)
666
+ }}
667
+ darkMode={darkMode}
668
+ toggleTheme={() => setDarkMode(!darkMode)}
669
+ userProfile={userProfile}
670
+ />
671
+
672
+ <main className="main-content">
673
+ <Header
674
+ onOpenSidebar={() => setSidebarOpen(true)}
675
+ isMobile={isMobile}
676
+ currentConversationId={currentConversation}
677
+ onDeleteConversation={deleteConversation}
678
+ onRenameConversation={renameConversation}
679
+ onTogglePin={togglePin}
680
+ onToggleArchive={toggleArchive}
681
+ currentChat={conversations.find(c => c.id === currentConversation)}
682
+ onRenameClick={() => {
683
+ const currentConv = conversations.find(c => c.id === currentConversation)
684
+ setModalTempTitle(currentConv?.title || '')
685
+ setShowRenameModal(true)
686
+ sessionStorage.setItem('isNewRenameModal', 'true')
687
+ }}
688
+ title={conversations.find(c => c.id === currentConversation)?.title}
689
+ onSettingsClick={(tab = 'general') => {
690
+ setSettingsTab(tab)
691
+ setShowSettings(true)
692
+ }}
693
+ onToggleTheme={() => setDarkMode(!darkMode)}
694
+ darkMode={darkMode}
695
+ userProfile={userProfile}
696
+ onHelpClick={() => {
697
+ setTourVersion(prev => prev + 1)
698
+ setShowTour(true)
699
+ }}
700
+ />
701
+
702
+ {showRenameModal && (
703
+ <div className="rename-modal-overlay" onClick={() => setShowRenameModal(false)}>
704
+ <div className="rename-modal-content" onClick={e => e.stopPropagation()}>
705
+ <h3 className="rename-modal-title">Đổi tên đoạn chat</h3>
706
+ <input
707
+ className="premium-rename-input"
708
+ value={modalTempTitle}
709
+ onChange={(e) => setModalTempTitle(e.target.value)}
710
+ onKeyDown={(e) => {
711
+ if (e.key === 'Enter') handleSaveRename()
712
+ if (e.key === 'Escape') setShowRenameModal(false)
713
+ }}
714
+ autoFocus
715
+ onFocus={e => {
716
+ const isRestored = !sessionStorage.getItem('isNewRenameModal');
717
+ if (!isRestored) {
718
+ e.target.select();
719
+ sessionStorage.removeItem('isNewRenameModal');
720
+ }
721
+ }}
722
+ />
723
+ <div className="rename-modal-actions">
724
+ <button className="modal-btn modal-btn-cancel" onClick={() => setShowRenameModal(false)}>Hủy</button>
725
+ <button className="modal-btn modal-btn-save" onClick={handleSaveRename}>Lưu</button>
726
+ </div>
727
+ </div>
728
+ </div>
729
+ )}
730
+
731
+ {isLoading && (
732
+ <div className="loading-bar">
733
+ <div className="loading-progress"></div>
734
+ </div>
735
+ )}
736
+
737
+ <MessageList
738
+ messages={messages}
739
+ isLoading={isLoading}
740
+ conversationId={currentConversation}
741
+ onExampleClick={(text) => sendMessage(text, [])}
742
+ onImageClick={(images, index) => setViewerData({ images, index })}
743
+ userAvatar={userProfile.avatar}
744
+ userName={userProfile.name}
745
+ />
746
+
747
+ <ChatInput
748
+ onSendMessage={sendMessage}
749
+ isLoading={isLoading}
750
+ onImageClick={(images, index) => setViewerData({ images, index })}
751
+ />
752
+ </main>
753
+
754
+ {showSearch && (
755
+ <SearchModal
756
+ conversations={conversations}
757
+ onSelect={(id) => setCurrentConversation(id)}
758
+ onClose={() => setShowSearch(false)}
759
+ isRestored={isInitialMountRef.current}
760
+ />
761
+ )}
762
+
763
+ {showSettings && (
764
+ <SettingsModal
765
+ onClose={() => setShowSettings(false)}
766
+ darkMode={darkMode}
767
+ onToggleTheme={() => setDarkMode(!darkMode)}
768
+ archivedSessions={conversations.filter(c => c.isArchived)}
769
+ onRestoreSession={(id) => toggleArchive(id)}
770
+ initialTab={settingsTab}
771
+ userProfile={userProfile}
772
+ onUpdateProfile={handleUpdateProfile}
773
+ isRestored={isInitialMountRef.current}
774
+ onTabChange={setSettingsTab}
775
+ />
776
+ )}
777
+
778
+ {showPinLimitToast && (
779
+ <div className="premium-toast warning">
780
+ <div className="toast-icon">⚠️</div>
781
+ <div className="toast-content">
782
+ <strong>Giới hạn ghim</strong>
783
+ <p>Bạn chỉ có thể ghim tối đa 5 đoạn chat.</p>
784
+ </div>
785
+ </div>
786
+ )}
787
+ </>
788
+ )}
789
+
790
+ {/* Always render at fixed position to prevent DOM unmount/remount on isInitializing change */}
791
+ {viewerData && (
792
+ <ImageViewer
793
+ images={viewerData.images}
794
+ startIndex={viewerData.index}
795
+ onClose={() => setViewerData(null)}
796
+ onIndexChange={(newIndex) => {
797
+ setViewerData(prev => prev ? { ...prev, index: newIndex } : null)
798
+ }}
799
+ isRestored={isInitialMountRef.current || isInitializing}
800
+ />
801
+ )}
802
+ </div>
803
+ )
804
+ }
805
+
806
+ export default App
frontend/src/assets/react.svg ADDED
frontend/src/components/BlockRenderer.jsx ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * BlockRenderer - Renders structured message blocks
3
+ * NO hardcoded parsing - only handles proper JSON blocks from backend
4
+ */
5
+ import { useMemo } from 'react'
6
+ import { BlockMath, InlineMath } from 'react-katex'
7
+ import 'katex/dist/katex.min.css'
8
+
9
+ /**
10
+ * Render a TextBlock - plain text
11
+ */
12
+ const TextBlock = ({ content }) => {
13
+ if (!content) return null
14
+ return <p className="text-block">{content}</p>
15
+ }
16
+
17
+ /**
18
+ * Render a MathBlock - KaTeX for math expressions
19
+ * Accepts either 'latex' or 'content' prop for compatibility
20
+ */
21
+ const MathBlockRenderer = ({ latex, content, display = 'block' }) => {
22
+ // Support both 'latex' and 'content' field names
23
+ const mathContent = latex || content
24
+ if (!mathContent) return null
25
+
26
+ try {
27
+ if (display === 'inline') {
28
+ return <InlineMath math={mathContent} />
29
+ }
30
+ return (
31
+ <div className="math-block-container">
32
+ <BlockMath math={mathContent} />
33
+ </div>
34
+ )
35
+ } catch (error) {
36
+ console.error('KaTeX render error:', error)
37
+ return <code className="math-error">{mathContent}</code>
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Render a ListBlock - ordered or unordered list
43
+ */
44
+ const ListBlockRenderer = ({ items, ordered = false }) => {
45
+ if (!items || !items.length) return null
46
+
47
+ const ListTag = ordered ? 'ol' : 'ul'
48
+ return (
49
+ <ListTag className="list-block">
50
+ {items.map((item, idx) => (
51
+ <li key={idx}>{item}</li>
52
+ ))}
53
+ </ListTag>
54
+ )
55
+ }
56
+
57
+ /**
58
+ * Render a CodeBlock - syntax highlighted code
59
+ */
60
+ const CodeBlockRenderer = ({ content, language = 'python' }) => {
61
+ if (!content) return null
62
+ return (
63
+ <pre className="code-block">
64
+ <code className={`language-${language}`}>{content}</code>
65
+ </pre>
66
+ )
67
+ }
68
+
69
+ /**
70
+ * Render a StepBlock - solution step with nested blocks
71
+ */
72
+ const StepBlockRenderer = ({ index, title, blocks }) => {
73
+ return (
74
+ <div className="step-block">
75
+ <div className="step-header">
76
+ <span className="step-number">{index}</span>
77
+ <span className="step-title">{title}</span>
78
+ </div>
79
+ <div className="step-content">
80
+ {blocks && blocks.map((block, idx) => (
81
+ <BlockRenderer key={idx} block={block} />
82
+ ))}
83
+ </div>
84
+ </div>
85
+ )
86
+ }
87
+
88
+ /**
89
+ * Main BlockRenderer - dispatches to appropriate renderer based on block type
90
+ */
91
+ const BlockRenderer = ({ block }) => {
92
+ if (!block || !block.type) return null
93
+
94
+ switch (block.type) {
95
+ case 'text':
96
+ return <TextBlock content={block.content} />
97
+
98
+ case 'math':
99
+ // Support both 'latex' and 'content' fields
100
+ return <MathBlockRenderer latex={block.latex} content={block.content} display={block.display} />
101
+
102
+ case 'list':
103
+ return <ListBlockRenderer items={block.items} ordered={block.ordered} />
104
+
105
+ case 'code':
106
+ return <CodeBlockRenderer content={block.content} language={block.language} />
107
+
108
+ case 'step':
109
+ return <StepBlockRenderer index={block.index} title={block.title} blocks={block.blocks} />
110
+
111
+ default:
112
+ console.warn('Unknown block type:', block.type)
113
+ return <p className="text-block">{JSON.stringify(block)}</p>
114
+ }
115
+ }
116
+
117
+ /**
118
+ * MessageBlocks - Renders an array of blocks for a message
119
+ * Only parses JSON - no hardcoded fallback parsing
120
+ */
121
+ export const MessageBlocks = ({ content }) => {
122
+ const blocks = useMemo(() => {
123
+ if (!content) return []
124
+
125
+ try {
126
+ // Parse JSON blocks from backend
127
+ const parsed = typeof content === 'string' ? JSON.parse(content) : content
128
+ if (parsed.blocks && Array.isArray(parsed.blocks)) {
129
+ return parsed.blocks
130
+ }
131
+ // If no blocks array, wrap as single text block
132
+ return [{ type: 'text', content: String(content) }]
133
+ } catch {
134
+ // JSON parse failed - backend didn't return proper format
135
+ // Show as plain text (no regex processing)
136
+ return [{ type: 'text', content: String(content) }]
137
+ }
138
+ }, [content])
139
+
140
+ if (!blocks.length) return null
141
+
142
+ return (
143
+ <div className="message-blocks">
144
+ {blocks.map((block, idx) => (
145
+ <BlockRenderer key={idx} block={block} />
146
+ ))}
147
+ </div>
148
+ )
149
+ }
150
+
151
+ export default BlockRenderer
frontend/src/components/ChatInput.jsx ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useRef, useState, useCallback, useEffect } from 'react'
2
+ import { useDropzone } from 'react-dropzone'
3
+ import { Send, Plus, X, ImageIcon, Paperclip, UploadCloud } from 'lucide-react'
4
+
5
+ const MAX_IMAGES = 5
6
+
7
+ const ChatInput = ({ onSendMessage, isLoading, onImageClick }) => {
8
+ const [input, setInput] = useState(() => sessionStorage.getItem('chat_draft') || '')
9
+ const [uploadedImages, setUploadedImages] = useState([])
10
+ const [showAttachMenu, setShowAttachMenu] = useState(false)
11
+ const [uploadError, setUploadError] = useState('')
12
+ const textareaRef = useRef(null)
13
+
14
+ // Draft persistence
15
+ useEffect(() => {
16
+ sessionStorage.setItem('chat_draft', input)
17
+ }, [input])
18
+
19
+ // Initial image restoration
20
+ useEffect(() => {
21
+ const savedImages = sessionStorage.getItem('chat_images')
22
+ if (savedImages) {
23
+ try {
24
+ const parsed = JSON.parse(savedImages)
25
+ const restored = parsed.map(img => {
26
+ // Convert base64 back to Blob/File if needed, or just use data URL as preview
27
+ return {
28
+ file: null, // Original file is lost on refresh, but we have the data
29
+ preview: img.data,
30
+ name: img.name,
31
+ type: img.type
32
+ }
33
+ })
34
+ setUploadedImages(restored)
35
+ } catch (e) {
36
+ console.error('Failed to restore images:', e)
37
+ }
38
+ }
39
+ }, [])
40
+
41
+ // Image persistence (Base64)
42
+ useEffect(() => {
43
+ if (uploadedImages.length === 0) {
44
+ sessionStorage.removeItem('chat_images')
45
+ return
46
+ }
47
+
48
+ // Only save images that have a preview (base64 or blob URL)
49
+ // If it's a blob URL, it won't survive refresh, so we need to ensure they are base64 when adding
50
+ const persistImages = async () => {
51
+ const dataToSave = await Promise.all(uploadedImages.map(async (img) => {
52
+ if (img.preview.startsWith('data:')) {
53
+ return { name: img.name, type: img.type, data: img.preview }
54
+ }
55
+ // If it's a blob URL, we should have converted it already on drop
56
+ return { name: img.name, type: img.type, data: img.preview }
57
+ }))
58
+ sessionStorage.setItem('chat_images', JSON.stringify(dataToSave))
59
+ }
60
+ persistImages()
61
+ }, [uploadedImages])
62
+
63
+ // Auto-resize textarea
64
+ useEffect(() => {
65
+ if (textareaRef.current) {
66
+ textareaRef.current.style.height = 'auto'
67
+ textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 200) + 'px'
68
+ }
69
+ }, [input])
70
+
71
+ // Clear error after 3 seconds
72
+ useEffect(() => {
73
+ if (uploadError) {
74
+ const timer = setTimeout(() => setUploadError(''), 3000)
75
+ return () => clearTimeout(timer)
76
+ }
77
+ }, [uploadError])
78
+
79
+ // Close menu when clicking outside
80
+ useEffect(() => {
81
+ const handleClickOutside = (e) => {
82
+ if (showAttachMenu && !e.target.closest('.attach-btn-wrapper')) {
83
+ setShowAttachMenu(false)
84
+ }
85
+ }
86
+ document.addEventListener('mousedown', handleClickOutside)
87
+ return () => document.removeEventListener('mousedown', handleClickOutside)
88
+ }, [showAttachMenu])
89
+
90
+ const handleKeyDown = (e) => {
91
+ if (e.key === 'Enter' && !e.shiftKey) {
92
+ e.preventDefault()
93
+ handleSubmit()
94
+ }
95
+ }
96
+
97
+ const onDrop = useCallback((acceptedFiles) => {
98
+ const processFiles = async () => {
99
+ const newImagesPromises = acceptedFiles.map(file => {
100
+ return new Promise((resolve) => {
101
+ const reader = new FileReader()
102
+ reader.onloadend = () => {
103
+ resolve({
104
+ file,
105
+ preview: reader.result,
106
+ name: file.name,
107
+ type: file.type
108
+ })
109
+ }
110
+ reader.readAsDataURL(file)
111
+ })
112
+ })
113
+
114
+ const newImages = await Promise.all(newImagesPromises)
115
+
116
+ setUploadedImages(prev => {
117
+ const remaining = MAX_IMAGES - prev.length
118
+ if (remaining <= 0) {
119
+ setUploadError(`Bạn chỉ được tải tối đa ${MAX_IMAGES} ảnh`)
120
+ return prev
121
+ }
122
+ if (newImages.length > remaining) {
123
+ setUploadError(`Chỉ nhận thêm ${remaining} ảnh cuối cùng`)
124
+ }
125
+ return [...prev, ...newImages.slice(0, remaining)]
126
+ })
127
+ }
128
+
129
+ processFiles()
130
+ setShowAttachMenu(false)
131
+ }, [])
132
+
133
+ const { getRootProps, getInputProps, isDragActive, open: openFilePicker } = useDropzone({
134
+ onDrop,
135
+ accept: { 'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp'] },
136
+ maxFiles: MAX_IMAGES,
137
+ noClick: true,
138
+ noKeyboard: true,
139
+ disabled: uploadedImages.length >= MAX_IMAGES,
140
+ })
141
+
142
+ const removeImage = (index) => {
143
+ setUploadedImages(prev => prev.filter((_, i) => i !== index))
144
+ }
145
+
146
+ const clearAllImages = () => {
147
+ setUploadedImages([])
148
+ }
149
+
150
+ const handleSubmit = () => {
151
+ if ((!input.trim() && uploadedImages.length === 0) || isLoading) return
152
+
153
+ onSendMessage(input, uploadedImages)
154
+
155
+ // Clear persistence
156
+ setInput('')
157
+ setUploadedImages([])
158
+ sessionStorage.removeItem('chat_draft')
159
+ sessionStorage.removeItem('chat_images')
160
+
161
+ if (textareaRef.current) textareaRef.current.style.height = 'auto'
162
+ }
163
+
164
+ return (
165
+ <div className="input-container">
166
+ {/* Image Previews & Counter */}
167
+ {uploadedImages.length > 0 && (
168
+ <div className="image-attachment-area">
169
+ <div className="attachment-header">
170
+ <span className="attachment-counter">
171
+ Đã đính kèm: <strong>{uploadedImages.length}/{MAX_IMAGES}</strong> ảnh
172
+ </span>
173
+ <button type="button" className="clear-all-btn" onClick={clearAllImages}>
174
+ Xóa hết
175
+ </button>
176
+ </div>
177
+ <div className="uploaded-images-preview">
178
+ {uploadedImages.map((img, idx) => (
179
+ <div
180
+ key={idx}
181
+ className="preview-item clickable"
182
+ onClick={() => onImageClick?.(uploadedImages.map(img => img.preview), idx)}
183
+ title="Xem ảnh lớn"
184
+ >
185
+ <img src={img.preview} alt={`Preview ${idx + 1}`} />
186
+ <button
187
+ type="button"
188
+ className="remove-img-btn"
189
+ onClick={(e) => {
190
+ e.stopPropagation()
191
+ removeImage(idx)
192
+ }}
193
+ title="Xóa ảnh"
194
+ >
195
+ <X size={12} />
196
+ </button>
197
+ </div>
198
+ ))}
199
+ </div>
200
+ </div>
201
+ )}
202
+
203
+ {/* Upload Error Message */}
204
+ {uploadError && (
205
+ <div className="upload-error-toast">
206
+ {uploadError}
207
+ </div>
208
+ )}
209
+
210
+ <div className={`input-wrapper ${isLoading ? 'opacity-50' : ''}`} {...getRootProps()} id="tour-chat-input-area">
211
+ <input {...getInputProps()} />
212
+
213
+ {/* Drag Overlay - Compact & Premium */}
214
+ {isDragActive && (
215
+ <div className="drag-overlay">
216
+ <div className="drag-content">
217
+ <UploadCloud size={24} className="drag-icon" />
218
+ <span className="drag-text">Thả ảnh vào đây</span>
219
+ </div>
220
+ </div>
221
+ )}
222
+
223
+ <div className="attach-btn-wrapper">
224
+ <button
225
+ type="button"
226
+ className="attach-btn"
227
+ onClick={(e) => {
228
+ e.stopPropagation();
229
+ setShowAttachMenu(!showAttachMenu);
230
+ }}
231
+ title="Đính kèm"
232
+ id="tour-attach"
233
+ >
234
+ <Plus size={20} className={showAttachMenu ? 'rotate-45 transition-transform' : 'transition-transform'} />
235
+ </button>
236
+
237
+ {showAttachMenu && (
238
+ <div className="attachment-menu">
239
+ <button type="button" onClick={(e) => { e.stopPropagation(); openFilePicker(); setShowAttachMenu(false) }}>
240
+ <ImageIcon size={18} /> Ảnh/Video
241
+ </button>
242
+ <button type="button" onClick={(e) => { e.stopPropagation(); setShowAttachMenu(false) }} className="opacity-50 cursor-not-allowed">
243
+ <Paperclip size={18} /> Tài liệu (Sắp ra mắt)
244
+ </button>
245
+ </div>
246
+ )}
247
+ </div>
248
+
249
+ <textarea
250
+ ref={textareaRef}
251
+ value={input}
252
+ onChange={(e) => setInput(e.target.value)}
253
+ onKeyDown={handleKeyDown}
254
+ placeholder="Nhập tin nhắn..."
255
+ rows={1}
256
+ disabled={isLoading}
257
+ id="tour-chat-input"
258
+ />
259
+
260
+ <button
261
+ type="button"
262
+ className="send-btn"
263
+ onClick={(e) => {
264
+ e.stopPropagation(); // Prevent bubbling to dropzone
265
+ handleSubmit();
266
+ }}
267
+ disabled={isLoading || (!input.trim() && uploadedImages.length === 0)}
268
+ title="Gửi"
269
+ id="tour-send"
270
+ >
271
+ <Send size={18} />
272
+ </button>
273
+ </div>
274
+
275
+ <p className="input-hint">
276
+ AI có thể mắc lỗi. Hãy kiểm tra lại thông tin quan trọng.
277
+ </p>
278
+ </div>
279
+ )
280
+ }
281
+
282
+ export default ChatInput
frontend/src/components/ErrorBoundary.jsx ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+
3
+ class ErrorBoundary extends React.Component {
4
+ constructor(props) {
5
+ super(props)
6
+ this.state = { hasError: false }
7
+ }
8
+
9
+ static getDerivedStateFromError(error) {
10
+ return { hasError: true }
11
+ }
12
+
13
+ componentDidCatch(error, errorInfo) {
14
+ console.error("Markdown rendering error:", error, errorInfo)
15
+ }
16
+
17
+ render() {
18
+ if (this.state.hasError) {
19
+ return this.props.fallback || <span className="text-red-500 text-sm">⚠️ Không thể hiển thị nội dung này</span>
20
+ }
21
+
22
+ return this.props.children
23
+ }
24
+ }
25
+
26
+ export default ErrorBoundary
frontend/src/components/GuideTour.jsx ADDED
@@ -0,0 +1,511 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useMemo } from 'react';
2
+ import Joyride, { STATUS } from 'react-joyride';
3
+
4
+
5
+ const GuideTour = ({ darkMode, tourVersion, onTourEnd }) => {
6
+ const steps = useMemo(() => [
7
+ {
8
+ target: 'body',
9
+ title: 'Xin chào, tôi là Pochi',
10
+ content: 'Cùng bắt đầu khám phá không gian làm việc của bạn nhé!',
11
+ placement: 'center',
12
+ disableBeacon: true,
13
+ },
14
+ {
15
+ target: 'body',
16
+ title: 'Thao tác nhanh',
17
+ content: (
18
+ <div className="tour-shortcuts-container">
19
+ <div className="shortcuts-intro">Sử dụng bàn phím để điều khiển linh hoạt:</div>
20
+ <div className="shortcuts-grid">
21
+ <span className="kbd-bullet">•</span>
22
+ <div className="kbd-center">
23
+ <span className="kbd-key">←</span>
24
+ <span className="kbd-key">→</span>
25
+ </div>
26
+ <span className="kbd-colon">:</span>
27
+ <span className="kbd-text">Điều hướng các bước.</span>
28
+
29
+ <span className="kbd-bullet">•</span>
30
+ <div className="kbd-center">
31
+ <span className="kbd-key">Esc</span>
32
+ </div>
33
+ <span className="kbd-colon">:</span>
34
+ <span className="kbd-text">Bỏ qua hướng dẫn nhanh.</span>
35
+
36
+ <span className="kbd-bullet">•</span>
37
+ <div className="kbd-center">
38
+ <span className="kbd-key">Enter</span>
39
+ </div>
40
+ <span className="kbd-colon">:</span>
41
+ <span className="kbd-text">Hoàn tất hướng dẫn.</span>
42
+ </div>
43
+ </div>
44
+ ),
45
+ placement: 'center',
46
+ disableBeacon: true,
47
+ },
48
+ {
49
+ target: '#tour-new-chat',
50
+ title: 'Đoạn chat mới',
51
+ content: 'Nơi bạn bắt đầu những cuộc trò chuyện mới với Pochi linh hoạt và nhanh chóng.',
52
+ disableBeacon: true,
53
+ },
54
+ {
55
+ target: '#tour-chat-input-area',
56
+ title: 'Khung gửi tin nhắn',
57
+ content: 'Nhập nội dung câu hỏi, đính kèm hình ảnh để trò chuyện cùng Pochi.',
58
+ disableBeacon: true,
59
+ },
60
+ {
61
+ target: '#tour-chat-interface',
62
+ title: 'Giao diện trò chuyện',
63
+ content: 'Khu vực hiển thị nội dung trao đổi giữa bạn và Pochi một cách trực quan.',
64
+ disableBeacon: true,
65
+ },
66
+ {
67
+ target: '#tour-history-section',
68
+ title: 'Lịch sử trò chuyện',
69
+ content: 'Toàn bộ các cuộc trò chuyện của bạn đều được lưu trữ an toàn tại đây.',
70
+ spotlightPadding: 5,
71
+ disableBeacon: true,
72
+ },
73
+ {
74
+ target: '#tour-chat-features',
75
+ title: 'Tính năng nâng cao',
76
+ content: 'Bạn có thể ghim, đổi tên, lưu trữ hoặc xóa các cuộc trò chuyện thông qua menu này.',
77
+ spotlightPadding: 5,
78
+ disableBeacon: true,
79
+ },
80
+ {
81
+ target: '#tour-profile-header-avatar',
82
+ title: 'Cài đặt cá nhân (Header)',
83
+ content: 'Bạn có thể truy cập cài đặt, quản lý tài khoản hoặc đăng xuất nhanh tại logo góc trên này.',
84
+ spotlightPadding: 5,
85
+ disableBeacon: true,
86
+ },
87
+ {
88
+ target: '#tour-profile-sidebar-avatar',
89
+ title: 'Cài đặt cá nhân (Sidebar)',
90
+ content: 'Hoặc bạn cũng có thể thay đổi chế độ tối / sáng và xem tin nhắn lưu trữ tại logo phía dưới này.',
91
+ spotlightPadding: 5,
92
+ disableBeacon: true,
93
+ },
94
+ {
95
+ target: '#tour-search',
96
+ title: 'Tìm kiếm đoạn chat',
97
+ content: 'Giúp bạn tìm lại những thông tin cũ trong lịch sử trò chuyện chỉ với vài phím gõ.',
98
+ spotlightPadding: 5,
99
+ disableBeacon: true,
100
+ },
101
+ {
102
+ target: '#tour-toggle-sidebar',
103
+ title: 'Thu gọn thanh bên',
104
+ content: 'Tối ưu không gian làm việc bằng cách thu gọn thanh bên khi cần thiết.',
105
+ spotlightPadding: 5,
106
+ disableBeacon: true,
107
+ },
108
+ {
109
+ target: '#tour-help-btn',
110
+ title: 'Trợ giúp & Hướng dẫn',
111
+ content: 'Bất cứ lúc nào bạn cần, hãy nhấn vào đây để xem lại hướng dẫn sử dụng nhé!',
112
+ disableBeacon: true,
113
+ },
114
+ {
115
+ target: 'body',
116
+ title: 'Tổng hợp phím tắt',
117
+ content: (
118
+ <div className="tour-shortcuts-container">
119
+ <div className="shortcuts-intro">Làm chủ ứng dụng qua các phím tắt nhanh:</div>
120
+ <div className="shortcuts-grid">
121
+ <span className="kbd-bullet">•</span>
122
+ <div className="kbd-center">
123
+ <span className="kbd-key">{navigator.platform.toUpperCase().indexOf('MAC') >= 0 ? '⌘' : 'Ctrl'}</span>
124
+ <span className="kbd-key">F</span>
125
+ </div>
126
+ <span className="kbd-colon">:</span>
127
+ <span className="kbd-text">Tìm kiếm trò chuyện.</span>
128
+
129
+ <span className="kbd-bullet">•</span>
130
+ <div className="kbd-center">
131
+ <span className="kbd-key">{navigator.platform.toUpperCase().indexOf('MAC') >= 0 ? '⌘' : 'Ctrl'}</span>
132
+ <span className="kbd-key">E</span>
133
+ </div>
134
+ <span className="kbd-colon">:</span>
135
+ <span className="kbd-text">Tạo đoạn chat mới.</span>
136
+
137
+ <span className="kbd-bullet">•</span>
138
+ <div className="kbd-center">
139
+ <span className="kbd-key">{navigator.platform.toUpperCase().indexOf('MAC') >= 0 ? '⌘' : 'Ctrl'}</span>
140
+ <span className="kbd-key">K</span>
141
+ </div>
142
+ <span className="kbd-colon">:</span>
143
+ <span className="kbd-text">Bật/tắt chế độ tối.</span>
144
+
145
+ <span className="kbd-bullet">•</span>
146
+ <div className="kbd-center">
147
+ <span className="kbd-key">{navigator.platform.toUpperCase().indexOf('MAC') >= 0 ? '⌘' : 'Ctrl'}</span>
148
+ <span className="kbd-key">X</span>
149
+ </div>
150
+ <span className="kbd-colon">:</span>
151
+ <span className="kbd-text">Mở cài đặt chung.</span>
152
+
153
+ <span className="kbd-bullet">•</span>
154
+ <div className="kbd-center">
155
+ <span className="kbd-key">{navigator.platform.toUpperCase().indexOf('MAC') >= 0 ? '⌘' : 'Ctrl'}</span>
156
+ <span className="kbd-key">I</span>
157
+ </div>
158
+ <span className="kbd-colon">:</span>
159
+ <span className="kbd-text">Thông tin tài khoản.</span>
160
+ </div>
161
+ <div style={{
162
+ marginTop: '20px',
163
+ fontSize: '0.95rem',
164
+ fontWeight: 600,
165
+ color: darkMode ? 'var(--text-secondary)' : '#4b5563',
166
+ textAlign: 'center',
167
+ paddingTop: '12px',
168
+ borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)'}`
169
+ }}>
170
+ Ấn phím tắt <span className="kbd-key">Enter</span> để hoàn tất hướng dẫn.
171
+ </div>
172
+ </div>
173
+ ),
174
+ placement: 'center',
175
+ disableBeacon: true,
176
+ }
177
+ ], [darkMode]);
178
+
179
+ const [run, setRun] = useState(false);
180
+ const [stepIndex, setStepIndex] = useState(() => {
181
+ const savedIndex = localStorage.getItem('tourStepIndex');
182
+ return (savedIndex && savedIndex !== '-1') ? parseInt(savedIndex, 10) : 0;
183
+ });
184
+
185
+ const finalizeTour = () => {
186
+ setRun(false);
187
+ setStepIndex(0);
188
+ localStorage.setItem('hasSeenTour', 'true');
189
+ localStorage.setItem('tourStepIndex', '-1');
190
+
191
+ if (onTourEnd) onTourEnd();
192
+
193
+ // Remove focus from whatever triggered the end/skip to prevent persistent focus outlines
194
+ if (document.activeElement && typeof document.activeElement.blur === 'function') {
195
+ document.activeElement.blur();
196
+ }
197
+ };
198
+
199
+ const handleJoyrideCallback = (data) => {
200
+ const { action, index, status, type, size } = data;
201
+
202
+ if (type === 'step:after' || type === 'target:not_found') {
203
+ const isLastStep = index === size - 1;
204
+ if (isLastStep && action === 'next') {
205
+ finalizeTour();
206
+ return;
207
+ }
208
+
209
+ const nextIndex = index + (action === 'next' ? 1 : -1);
210
+ if (nextIndex >= 0 && nextIndex < size) {
211
+ setStepIndex(nextIndex);
212
+ localStorage.setItem('tourStepIndex', nextIndex.toString());
213
+ }
214
+ }
215
+
216
+ const finishedStatuses = [STATUS.FINISHED, STATUS.SKIPPED];
217
+ if (finishedStatuses.includes(status)) {
218
+ finalizeTour();
219
+ }
220
+ };
221
+
222
+ useEffect(() => {
223
+ const hasSeenTour = localStorage.getItem('hasSeenTour');
224
+ const savedIndex = localStorage.getItem('tourStepIndex');
225
+
226
+ if (tourVersion > 0) {
227
+ setStepIndex(0);
228
+ localStorage.setItem('tourStepIndex', '0');
229
+ setRun(true);
230
+ } else if (tourVersion === -1) {
231
+ setRun(false);
232
+ } else if (!hasSeenTour || (savedIndex !== null && savedIndex !== '-1')) {
233
+ setRun(true);
234
+ } else {
235
+ setRun(false);
236
+ }
237
+ }, [tourVersion]);
238
+
239
+ useEffect(() => {
240
+ if (!run) return;
241
+
242
+ const handleKeyDown = (e) => {
243
+ const isLastStep = stepIndex === steps.length - 1;
244
+ const isFirstStep = stepIndex === 0;
245
+
246
+ switch (e.key) {
247
+ case 'ArrowRight':
248
+ if (!isLastStep) {
249
+ e.preventDefault();
250
+ const nextIndex = stepIndex + 1;
251
+ setStepIndex(nextIndex);
252
+ localStorage.setItem('tourStepIndex', nextIndex.toString());
253
+ }
254
+ break;
255
+ case 'ArrowLeft':
256
+ if (!isFirstStep) {
257
+ e.preventDefault();
258
+ const prevIndex = stepIndex - 1;
259
+ setStepIndex(prevIndex);
260
+ localStorage.setItem('tourStepIndex', prevIndex.toString());
261
+ }
262
+ break;
263
+ case 'Enter':
264
+ e.preventDefault();
265
+ if (isLastStep) {
266
+ finalizeTour();
267
+ }
268
+ break;
269
+ case 'Escape':
270
+ e.preventDefault();
271
+ finalizeTour();
272
+ break;
273
+ default:
274
+ break;
275
+ }
276
+ };
277
+
278
+ window.addEventListener('keydown', handleKeyDown);
279
+ return () => window.removeEventListener('keydown', handleKeyDown);
280
+ }, [run, stepIndex, steps]);
281
+
282
+ const CustomTooltip = ({
283
+ index,
284
+ step,
285
+ size,
286
+ backProps,
287
+ primaryProps,
288
+ skipProps,
289
+ tooltipProps,
290
+ arrowProps,
291
+ isLastStep
292
+ }) => {
293
+ const safeTooltipProps = tooltipProps || {};
294
+ const placement = safeTooltipProps['data-placement'] || step?.placement || 'bottom';
295
+
296
+ return (
297
+ <div {...safeTooltipProps} className="tour-tooltip glass" data-placement={placement}>
298
+ <div className="tour-header">
299
+ <div className="tour-header-left">
300
+ <span className="tour-progress">{index + 1} / {size}</span>
301
+ </div>
302
+ <div className="tour-header-right">
303
+ {index > 0 && (
304
+ <button {...backProps} className="tour-btn-nav tour-btn-back-header">
305
+ Quay lại
306
+ </button>
307
+ )}
308
+ <button {...primaryProps} className="tour-btn-nav tour-btn-next-header">
309
+ {isLastStep ? 'Hoàn tất' : 'Tiếp theo'}
310
+ </button>
311
+ </div>
312
+ </div>
313
+
314
+ <div className="tour-content">
315
+ {step?.title && <h4 className="tour-title">{step.title}</h4>}
316
+ <div className="tour-body">{step?.content}</div>
317
+ </div>
318
+
319
+ <div className="tour-footer">
320
+ {!isLastStep && (
321
+ <button {...skipProps} className="tour-btn-skip">
322
+ Bỏ qua
323
+ </button>
324
+ )}
325
+ </div>
326
+
327
+ <style dangerouslySetInnerHTML={{
328
+ __html: `
329
+ .tour-tooltip {
330
+ max-width: 360px;
331
+ padding: 20px;
332
+ border-radius: 20px;
333
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
334
+ font-family: 'Inter', sans-serif;
335
+ color: var(--text-primary);
336
+ border: 1px solid var(--border-light);
337
+ position: relative;
338
+ background: ${darkMode ? 'var(--bg-surface)' : '#f9fafb'} !important;
339
+ }
340
+ .tour-header {
341
+ display: flex;
342
+ align-items: center;
343
+ justify-content: space-between;
344
+ margin-bottom: 20px;
345
+ padding-bottom: 12px;
346
+ border-bottom: 1px solid var(--border-light);
347
+ }
348
+ .tour-header-right {
349
+ display: flex;
350
+ gap: 8px;
351
+ }
352
+ .tour-progress {
353
+ font-size: 0.75rem;
354
+ font-weight: 700;
355
+ color: var(--brand-primary);
356
+ background: var(--bg-surface-hover);
357
+ padding: 4px 10px;
358
+ border-radius: 12px;
359
+ font-family: 'Outfit', sans-serif;
360
+ }
361
+ .tour-btn-nav {
362
+ border: none;
363
+ padding: 6px 14px;
364
+ border-radius: 8px;
365
+ font-weight: 700;
366
+ font-size: 0.85rem;
367
+ cursor: pointer;
368
+ transition: background 0.2s, color 0.2s;
369
+ font-family: 'Outfit', sans-serif;
370
+ outline: none !important;
371
+ }
372
+ .tour-btn-next-header {
373
+ background: var(--brand-primary);
374
+ color: white;
375
+ }
376
+ .tour-btn-next-header:hover {
377
+ background: var(--brand-hover);
378
+ }
379
+ .tour-btn-back-header {
380
+ background: var(--bg-surface-hover);
381
+ color: var(--text-secondary);
382
+ }
383
+ .tour-btn-back-header:hover {
384
+ color: var(--text-primary);
385
+ background: var(--border-light);
386
+ }
387
+ .tour-title {
388
+ font-family: 'Outfit', sans-serif;
389
+ font-size: 1.2rem;
390
+ font-weight: 800;
391
+ margin: 0 0 10px 0;
392
+ color: var(--text-primary);
393
+ letter-spacing: -0.01em;
394
+ }
395
+ .tour-body {
396
+ font-size: 0.95rem;
397
+ line-height: 1.6;
398
+ color: ${darkMode ? 'var(--text-secondary)' : '#4b5563'};
399
+ white-space: pre-wrap;
400
+ font-weight: 600;
401
+ }
402
+ .tour-footer {
403
+ margin-top: 16px;
404
+ display: flex;
405
+ justify-content: flex-start;
406
+ }
407
+ .tour-btn-skip {
408
+ background: transparent;
409
+ color: var(--text-tertiary);
410
+ border: none;
411
+ padding: 4px 0;
412
+ font-weight: 600;
413
+ font-size: 0.85rem;
414
+ cursor: pointer;
415
+ transition: color 0.2s;
416
+ text-decoration: underline;
417
+ text-underline-offset: 4px;
418
+ font-family: 'Outfit', sans-serif;
419
+ outline: none !important;
420
+ }
421
+ .tour-btn-skip:hover {
422
+ color: var(--text-primary);
423
+ }
424
+ .kbd-key {
425
+ background: ${darkMode ? '#2d2d2d' : '#ffffff'};
426
+ border: 1px solid ${darkMode ? '#444' : '#94a3b8'};
427
+ border-bottom-width: 3px;
428
+ border-radius: 6px;
429
+ padding: 1px 8px;
430
+ font-size: 0.8rem;
431
+ font-weight: 800;
432
+ color: ${darkMode ? 'var(--text-primary)' : '#1e293b'};
433
+ font-family: 'Inter', system-ui, sans-serif;
434
+ display: inline-flex;
435
+ align-items: center;
436
+ justify-content: center;
437
+ min-width: 24px;
438
+ height: 24px;
439
+ margin: 0 2px;
440
+ box-shadow: 0 2px 0 ${darkMode ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0.15)'};
441
+ }
442
+ .tour-shortcuts-container {
443
+ text-align: left;
444
+ font-weight: 600;
445
+ }
446
+ .shortcuts-intro {
447
+ margin-bottom: 12px;
448
+ color: var(--text-primary);
449
+ }
450
+ .shortcuts-grid {
451
+ display: grid;
452
+ grid-template-columns: 20px 80px 10px 1fr;
453
+ align-items: center;
454
+ gap: 6px 0;
455
+ }
456
+ .kbd-bullet {
457
+ color: var(--text-tertiary);
458
+ font-size: 1.2rem;
459
+ }
460
+ .kbd-center {
461
+ display: flex;
462
+ justify-content: center;
463
+ gap: 6px;
464
+ }
465
+ .kbd-colon {
466
+ text-align: center;
467
+ font-weight: 800;
468
+ color: var(--text-secondary);
469
+ }
470
+ .kbd-text {
471
+ padding-left: 8px;
472
+ color: ${darkMode ? 'var(--text-secondary)' : '#4b5563'};
473
+ }
474
+ `}} />
475
+ </div>
476
+ );
477
+ };
478
+
479
+ return (
480
+ <Joyride
481
+ key={tourVersion}
482
+ steps={steps}
483
+ run={run}
484
+ stepIndex={stepIndex}
485
+ continuous
486
+ showSkipButton
487
+ showProgress
488
+ disableOverlayClose={true}
489
+ disableBeacon={true}
490
+ disableScrolling={false}
491
+ tooltipComponent={CustomTooltip}
492
+ callback={handleJoyrideCallback}
493
+ styles={{
494
+ options: {
495
+ arrowColor: 'transparent',
496
+ overlayColor: 'rgba(0, 0, 0, 0.7)',
497
+ zIndex: 10000,
498
+ },
499
+ spotlight: {
500
+ borderRadius: 8,
501
+ border: `4px dashed ${darkMode ? 'rgba(255, 255, 255, 0.7)' : '#334155'}`,
502
+ },
503
+ beacon: {
504
+ display: 'none',
505
+ }
506
+ }}
507
+ />
508
+ );
509
+ };
510
+
511
+ export default GuideTour;
frontend/src/components/Header.jsx ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef, useEffect } from 'react'
2
+ import { Share2, User, MoreHorizontal, Sparkles, ChevronDown, Edit3, Pin, Archive, Trash2, Settings, LogOut, Moon, Sun, HelpCircle } from 'lucide-react'
3
+
4
+ const Header = ({ title, onOpenSidebar, isMobile, currentConversationId, onDeleteConversation, onRenameConversation, onRenameClick, onTogglePin, onToggleArchive, currentChat, onSettingsClick, onToggleTheme, darkMode, userProfile, onHelpClick }) => {
5
+ const [showMenu, setShowMenu] = useState(() => {
6
+ return sessionStorage.getItem('showHeaderMenu') === 'true'
7
+ })
8
+ const [showUserMenu, setShowUserMenu] = useState(() => {
9
+ return sessionStorage.getItem('showUserMenu') === 'true'
10
+ })
11
+ const menuRef = useRef(null)
12
+ const userMenuRef = useRef(null)
13
+
14
+ useEffect(() => {
15
+ sessionStorage.setItem('showHeaderMenu', showMenu)
16
+ }, [showMenu])
17
+
18
+ useEffect(() => {
19
+ sessionStorage.setItem('showUserMenu', showUserMenu)
20
+ }, [showUserMenu])
21
+
22
+ // Close menus when clicking outside
23
+ useEffect(() => {
24
+ const handleClickOutside = (event) => {
25
+ if (menuRef.current && !menuRef.current.contains(event.target)) {
26
+ setShowMenu(false)
27
+ }
28
+ if (userMenuRef.current && !userMenuRef.current.contains(event.target)) {
29
+ setShowUserMenu(false)
30
+ }
31
+ }
32
+ if (showMenu || showUserMenu) {
33
+ document.addEventListener('mousedown', handleClickOutside)
34
+ }
35
+ return () => document.removeEventListener('mousedown', handleClickOutside)
36
+ }, [showMenu, showUserMenu])
37
+
38
+ return (
39
+ <header className="premium-header">
40
+ <div className="header-left">
41
+ <div className="brand-wrapper">
42
+ <span className="brand-name">Pochi <span className="brand-version">4.o</span></span>
43
+ <ChevronDown size={16} className="brand-dropdown-icon" />
44
+ </div>
45
+ </div>
46
+
47
+ <div className="header-center">
48
+ <button className="upgrade-pill" id="tour-upgrade">
49
+ <Sparkles size={14} className="sparkle-icon" />
50
+ <span>Nâng cấp lên Pro</span>
51
+ </button>
52
+ </div>
53
+
54
+ <div className="header-right">
55
+ <button
56
+ className="header-action-btn help-btn"
57
+ id="tour-help-btn"
58
+ title="Hướng dẫn sử dụng"
59
+ onClick={onHelpClick}
60
+ style={{ fontFamily: "'Outfit', sans-serif", fontWeight: 600 }}
61
+ >
62
+ <HelpCircle size={18} />
63
+ <span className="btn-label" style={{ marginTop: '1px' }}>Hướng dẫn</span>
64
+ </button>
65
+
66
+ <div className="user-avatar-container" ref={userMenuRef}>
67
+ <div
68
+ className={`user-avatar-wrapper ${showUserMenu ? 'active' : ''}`}
69
+ onClick={() => setShowUserMenu(!showUserMenu)}
70
+ id="tour-profile-header"
71
+ >
72
+ <div className="user-avatar" id="tour-profile-header-avatar">
73
+ {userProfile.avatar ? (
74
+ <img src={userProfile.avatar} alt="Avatar" />
75
+ ) : (
76
+ <div className="avatar-circle small">{userProfile?.name?.charAt(0) || 'U'}</div>
77
+ )}
78
+ </div>
79
+ </div>
80
+
81
+ {showUserMenu && (
82
+ <div className="context-menu user-dropdown">
83
+ <div className="user-dropdown-header">
84
+ <div className="user-info">
85
+ <span className="user-name">{userProfile.name}</span>
86
+ <span className="user-email">{userProfile.email}</span>
87
+ </div>
88
+ </div>
89
+ <div className="divider" />
90
+ <button onClick={() => { onSettingsClick('account'); setShowUserMenu(false) }}>
91
+ <User size={14} />
92
+ <span>Tài khoản</span>
93
+ <div className="shortcut-hint">
94
+ {navigator.platform.toUpperCase().indexOf('MAC') >= 0 ? '⌘I' : 'Ctrl I'}
95
+ </div>
96
+ </button>
97
+ <button onClick={() => { onSettingsClick('general'); setShowUserMenu(false) }}>
98
+ <Settings size={14} />
99
+ <span>Cài đặt</span>
100
+ <div className="shortcut-hint">
101
+ {navigator.platform.toUpperCase().indexOf('MAC') >= 0 ? '⌘X' : 'Ctrl X'}
102
+ </div>
103
+ </button>
104
+ <button onClick={() => { onToggleTheme() }}>
105
+ {darkMode ? <Sun size={14} /> : <Moon size={14} />}
106
+ <span>{darkMode ? 'Chế độ sáng' : 'Chế độ tối'}</span>
107
+ <div className="shortcut-hint">
108
+ {navigator.platform.toUpperCase().indexOf('MAC') >= 0 ? '⌘K' : 'Ctrl K'}
109
+ </div>
110
+ </button>
111
+ <div className="divider" />
112
+ <button className="danger" onClick={() => setShowUserMenu(false)}>
113
+ <LogOut size={14} /> Đăng xuất
114
+ </button>
115
+ </div>
116
+ )}
117
+ </div>
118
+
119
+ <div className="header-menu-container" ref={menuRef}>
120
+ <button
121
+ className={`header-icon-btn more-btn ${showMenu ? 'active' : ''}`}
122
+ onClick={() => setShowMenu(!showMenu)}
123
+ disabled={!currentConversationId}
124
+ id="tour-chat-features"
125
+ >
126
+ <MoreHorizontal size={20} />
127
+ </button>
128
+
129
+ {showMenu && currentConversationId && (
130
+ <div className="context-menu header-dropdown">
131
+ <button onClick={() => {
132
+ onRenameClick()
133
+ setShowMenu(false)
134
+ }}>
135
+ <Edit3 size={14} /> Đổi tên
136
+ </button>
137
+ <button onClick={() => { onTogglePin(currentConversationId); setShowMenu(false) }}>
138
+ <Pin size={14} className={currentChat?.isPinned ? 'fill-current' : ''} />
139
+ {currentChat?.isPinned ? 'Bỏ ghim' : 'Ghim'}
140
+ </button>
141
+ <button onClick={() => { onToggleArchive(currentConversationId); setShowMenu(false) }}>
142
+ <Archive size={14} />
143
+ {currentChat?.isArchived ? 'Bỏ lưu trữ' : 'Lưu trữ'}
144
+ </button>
145
+ <div className="divider" />
146
+ <button
147
+ onClick={() => { onDeleteConversation(currentConversationId); setShowMenu(false) }}
148
+ className="danger"
149
+ >
150
+ <Trash2 size={14} /> Xóa
151
+ </button>
152
+ </div>
153
+ )}
154
+ </div>
155
+ </div>
156
+ </header>
157
+ )
158
+ }
159
+
160
+ export default Header
frontend/src/components/MessageList.jsx ADDED
@@ -0,0 +1,483 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react'
2
+ import ReactMarkdown from 'react-markdown'
3
+ import remarkMath from 'remark-math'
4
+ import remarkGfm from 'remark-gfm'
5
+ import rehypeKatex from 'rehype-katex'
6
+ import { Copy, Check, ChevronDown, ChevronUp, Bot, User, FileText, FileDown, FileCode, Download } from 'lucide-react'
7
+ import { jsPDF } from 'jspdf'
8
+ import html2canvas from 'html2canvas'
9
+ import { preprocessLaTeX, parseMessageContent } from '../utils/chatUtils'
10
+
11
+ import ErrorBoundary from './ErrorBoundary'
12
+
13
+ const MessageFooter = ({ role, onCopy, onToggleExpand, isExpanded, isOverflow, copiedId, idx, onExportMD, onExportPDF, onExportLaTeX }) => {
14
+ const [showMenu, setShowMenu] = useState(false)
15
+
16
+ return (
17
+ <div className="message-footer">
18
+ {isOverflow && (
19
+ <button className="footer-btn expand-toggle" onClick={onToggleExpand} title={isExpanded ? "Thu gọn" : "Xem thêm"}>
20
+ {isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
21
+ </button>
22
+ )}
23
+ <div className="footer-actions-right">
24
+ <button
25
+ onClick={onCopy}
26
+ title="Sao chép"
27
+ className={`footer-btn copy-btn ${copiedId === idx ? 'copied' : ''}`}
28
+ >
29
+ {copiedId === idx ? <Check size={16} /> : <Copy size={16} />}
30
+ {copiedId === idx && <span className="copy-toast">Đã copy</span>}
31
+ </button>
32
+
33
+ {role === 'assistant' && (
34
+ <div className="export-dropdown-wrapper">
35
+ <button
36
+ className="footer-btn export-trigger"
37
+ onClick={() => setShowMenu(!showMenu)}
38
+ title="Tải về"
39
+ >
40
+ <Download size={16} />
41
+ </button>
42
+ {showMenu && (
43
+ <div className="export-menu" onMouseLeave={() => setShowMenu(false)}>
44
+ <button onClick={() => { onExportMD(); setShowMenu(false); }}>
45
+ <FileText size={14} /> <span>Markdown (.md)</span>
46
+ </button>
47
+ <button onClick={() => { onExportLaTeX(); setShowMenu(false); }}>
48
+ <FileCode size={14} /> <span>LaTeX (.tex)</span>
49
+ </button>
50
+ <button onClick={() => { onExportPDF(); setShowMenu(false); }}>
51
+ <FileDown size={14} /> <span>PDF (.pdf)</span>
52
+ </button>
53
+ </div>
54
+ )}
55
+ </div>
56
+ )}
57
+ </div>
58
+ </div>
59
+ )
60
+ }
61
+
62
+ const CollapsibleContent = ({ content, messageId, maxLines = 12, isStreaming = false, children }) => {
63
+ const [expanded, setExpanded] = useState(() => {
64
+ if (!messageId) return false
65
+ return localStorage.getItem(messageId) === 'true'
66
+ })
67
+ // Maintain expanded state after streaming finishes for the current session
68
+ const [wasStreaming, setWasStreaming] = useState(false)
69
+
70
+ useEffect(() => {
71
+ if (isStreaming && !wasStreaming) {
72
+ setWasStreaming(true)
73
+ setExpanded(true)
74
+ }
75
+ }, [isStreaming, wasStreaming])
76
+
77
+ const [isOverflow, setIsOverflow] = useState(false)
78
+ const contentRef = useRef(null)
79
+
80
+ useEffect(() => {
81
+ if (contentRef.current) {
82
+ const lineHeight = parseFloat(getComputedStyle(contentRef.current).lineHeight)
83
+ const maxHeight = lineHeight * maxLines
84
+ setIsOverflow(contentRef.current.scrollHeight > maxHeight + 10)
85
+ }
86
+ }, [content, maxLines])
87
+
88
+ const toggleExpand = () => {
89
+ const nextState = !expanded
90
+ setExpanded(nextState)
91
+ if (messageId) {
92
+ localStorage.setItem(messageId, String(nextState))
93
+ }
94
+ }
95
+
96
+ return (
97
+ <div className={`collapsible-content ${expanded ? 'expanded' : ''} ${isOverflow && !expanded ? 'truncated' : ''}`}>
98
+ <div
99
+ ref={contentRef}
100
+ className="content-inner message-content"
101
+ style={(!expanded && isOverflow && !isStreaming) ? { maxHeight: `${maxLines * 1.7}em`, overflow: 'hidden' } : {}}
102
+ >
103
+ <ErrorBoundary>
104
+ <ReactMarkdown
105
+ remarkPlugins={[remarkMath, remarkGfm]}
106
+ rehypePlugins={[[rehypeKatex, { strict: false, trust: true, throwOnError: false }]]}
107
+ components={{
108
+ a: ({ node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />
109
+ }}
110
+ >
111
+ {preprocessLaTeX(parseMessageContent(content))}
112
+ </ReactMarkdown>
113
+ </ErrorBoundary>
114
+ </div>
115
+ {children({ isOverflow, isExpanded: expanded, onToggleExpand: toggleExpand })}
116
+ </div>
117
+ )
118
+ }
119
+
120
+ const MessageList = ({
121
+ messages,
122
+ isLoading,
123
+ conversationId,
124
+ onExampleClick,
125
+ onImageClick,
126
+ userAvatar,
127
+ userName = 'User'
128
+ }) => {
129
+ const messagesEndRef = useRef(null)
130
+ const containerRef = useRef(null)
131
+ const [showScrollBtn, setShowScrollBtn] = useState(false)
132
+ const [isRestored, setIsRestored] = useState(false)
133
+ const [isTransitioning, setIsTransitioning] = useState(false)
134
+ const scrollPositionsRef = useRef({}) // Persistent in-memory scroll storage
135
+
136
+ const prevConversationId = useRef(conversationId)
137
+ const prevMessagesLengthRef = useRef(0)
138
+ const prevStreamingRef = useRef(false)
139
+ const [copiedId, setCopiedId] = useState(null)
140
+ const pdfExportRef = useRef(null)
141
+ const [exportingIndex, setExportingIndex] = useState(null)
142
+
143
+ const scrollToBottom = (behavior = 'smooth') => {
144
+ if (containerRef.current) {
145
+ const container = containerRef.current
146
+ if (behavior === 'smooth') {
147
+ // Use requestAnimationFrame to ensure React has rendered the new content
148
+ requestAnimationFrame(() => {
149
+ container.scrollTo({
150
+ top: container.scrollHeight,
151
+ behavior: 'smooth'
152
+ })
153
+ })
154
+ } else {
155
+ container.scrollTop = container.scrollHeight
156
+ }
157
+ }
158
+ }
159
+
160
+ const handleScroll = useCallback(() => {
161
+ if (containerRef.current && conversationId && isRestored && !isTransitioning) {
162
+ const { scrollTop, scrollHeight, clientHeight } = containerRef.current
163
+ // Save to both Ref (instant access) and SessionStorage (persistence)
164
+ scrollPositionsRef.current[conversationId] = scrollTop
165
+ sessionStorage.setItem(`scroll_${conversationId}`, String(scrollTop))
166
+ setShowScrollBtn(scrollHeight - scrollTop - clientHeight > 100)
167
+ }
168
+ }, [conversationId, isRestored, isTransitioning])
169
+
170
+ // Detect Session Change
171
+ useLayoutEffect(() => {
172
+ if (prevConversationId.current !== conversationId) {
173
+ // Store current position before switching
174
+ if (containerRef.current && prevConversationId.current) {
175
+ scrollPositionsRef.current[prevConversationId.current] = containerRef.current.scrollTop
176
+ }
177
+
178
+ setIsRestored(false)
179
+ setIsTransitioning(true)
180
+ prevConversationId.current = conversationId
181
+ prevMessagesLengthRef.current = 0
182
+ }
183
+ }, [conversationId])
184
+
185
+ // Restore Scroll Position
186
+ useLayoutEffect(() => {
187
+ if (!containerRef.current || messages.length === 0 || !conversationId) {
188
+ if (messages.length === 0 && conversationId) {
189
+ setIsTransitioning(false) // No messages, nothing to restore
190
+ setIsRestored(true)
191
+ }
192
+ return
193
+ }
194
+ if (isRestored) return
195
+
196
+ const container = containerRef.current
197
+
198
+ // Priority: Ref -> SessionStorage -> Bottom
199
+ const savedScroll = scrollPositionsRef.current[conversationId] ??
200
+ sessionStorage.getItem(`scroll_${conversationId}`)
201
+
202
+ // Use 'auto' behavior for instant restoration (no jump)
203
+ if (savedScroll !== null) {
204
+ container.scrollTo({
205
+ top: parseInt(savedScroll, 10),
206
+ behavior: 'auto'
207
+ })
208
+ } else {
209
+ container.scrollTo({
210
+ top: container.scrollHeight,
211
+ behavior: 'auto'
212
+ })
213
+ }
214
+
215
+ // Force a layout reflow to ensure scroll is applied before showing
216
+ void container.offsetHeight
217
+
218
+ // If it's a session switch (isTransitioning), we use a small delay to hide the "jump"
219
+ if (isTransitioning) {
220
+ const timer = setTimeout(() => {
221
+ setIsRestored(true)
222
+ setIsTransitioning(false)
223
+ prevMessagesLengthRef.current = messages.length
224
+ }, 50)
225
+ return () => clearTimeout(timer)
226
+ } else {
227
+ // --- REFRESH CASE (INITIAL LOAD) ---
228
+ // Set isRestored to true to start animations
229
+ // Set prevMessagesLength to 0 to make ALL messages animate from the start
230
+ setIsRestored(true)
231
+ prevMessagesLengthRef.current = 0
232
+ }
233
+ }, [messages.length, conversationId, isRestored, isTransitioning])
234
+
235
+ const isInitialLoadRef = useRef(true)
236
+
237
+ // Handle New Messages
238
+ useEffect(() => {
239
+ if (!isRestored || isTransitioning || messages.length === 0) return
240
+
241
+ const isNewMessage = messages.length > prevMessagesLengthRef.current
242
+
243
+ // If it's the initial load (refresh), we want animations but NOT auto-scroll
244
+ if (isInitialLoadRef.current) {
245
+ isInitialLoadRef.current = false
246
+ prevMessagesLengthRef.current = messages.length
247
+ return
248
+ }
249
+
250
+ const lastMessage = messages[messages.length - 1]
251
+ const isUserMessage = lastMessage?.role === 'user'
252
+
253
+ if (isNewMessage) {
254
+ // Always scroll to bottom for user messages
255
+ // For bot messages, only scroll if already at bottom (sticky)
256
+ if (isUserMessage || !showScrollBtn) {
257
+ scrollToBottom('smooth')
258
+ }
259
+ }
260
+
261
+ prevMessagesLengthRef.current = messages.length
262
+ }, [messages.length, showScrollBtn, isRestored, isTransitioning])
263
+
264
+ // Auto-scroll during streaming to keep user's view locked on new content
265
+ const lastMessageContent = messages[messages.length - 1]?.content
266
+ const isLastMessageStreaming = messages[messages.length - 1]?.isStreaming
267
+
268
+ useEffect(() => {
269
+ if (!containerRef.current) return
270
+
271
+ if (isLastMessageStreaming) {
272
+ // "Sticky Scroll": Only auto-scroll if user is already near the bottom
273
+ // !showScrollBtn means distance to bottom < 100px
274
+ if (!showScrollBtn) {
275
+ containerRef.current.scrollTop = containerRef.current.scrollHeight
276
+ }
277
+ } else if (prevStreamingRef.current) {
278
+ // Just finished streaming - do one final sync to be sure
279
+ // but only if the user didn't scroll too far up
280
+ if (!showScrollBtn) {
281
+ scrollToBottom('smooth')
282
+ }
283
+ }
284
+
285
+ prevStreamingRef.current = isLastMessageStreaming
286
+ }, [lastMessageContent, isLastMessageStreaming, showScrollBtn])
287
+
288
+ const copyToClipboard = (text, idx) => {
289
+ navigator.clipboard.writeText(text)
290
+ setCopiedId(idx)
291
+ setTimeout(() => setCopiedId(null), 2000)
292
+ }
293
+
294
+ const exportToMarkdown = (content, idx) => {
295
+ const blob = new Blob([content], { type: 'text/markdown' })
296
+ const url = URL.createObjectURL(blob)
297
+ const a = document.createElement('a')
298
+ a.href = url
299
+ a.download = `chat-answer-${idx + 1}.md`
300
+ document.body.appendChild(a)
301
+ a.click()
302
+ document.body.removeChild(a)
303
+ URL.revokeObjectURL(url)
304
+ }
305
+
306
+ const exportToLaTeX = (content, idx) => {
307
+ const texContent = `\\documentclass{article}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amsmath}\n\n\\begin{document}\n${content}\n\\end{document}`
308
+ const blob = new Blob([texContent], { type: 'text/x-tex' })
309
+ const url = URL.createObjectURL(blob)
310
+ const a = document.createElement('a')
311
+ a.href = url
312
+ a.download = `chat-answer-${idx + 1}.tex`
313
+ document.body.appendChild(a)
314
+ a.click()
315
+ document.body.removeChild(a)
316
+ URL.revokeObjectURL(url)
317
+ }
318
+
319
+ const exportToPDF = async (idx) => {
320
+ setExportingIndex(idx)
321
+
322
+ // Wait for DOM to update the hidden element
323
+ setTimeout(async () => {
324
+ if (!pdfExportRef.current) return
325
+
326
+ try {
327
+ const canvas = await html2canvas(pdfExportRef.current, {
328
+ scale: 3,
329
+ useCORS: true,
330
+ backgroundColor: '#ffffff',
331
+ logging: false,
332
+ })
333
+
334
+ const imgData = canvas.toDataURL('image/png')
335
+ const pdfWidth = 595.28
336
+ const pdfHeight = 841.89
337
+ const pdf = new jsPDF('p', 'pt', 'a4')
338
+
339
+ const margin = 0 // Container already has padding
340
+ const innerWidth = pdfWidth
341
+ const imgProps = pdf.getImageProperties(imgData)
342
+ const imgHeight = (imgProps.height * innerWidth) / imgProps.width
343
+
344
+ let heightLeft = imgHeight
345
+ let position = 0
346
+
347
+ pdf.addImage(imgData, 'PNG', 0, position, innerWidth, imgHeight)
348
+ heightLeft -= pdfHeight
349
+
350
+ while (heightLeft >= 0) {
351
+ pdf.addPage()
352
+ position = heightLeft - imgHeight
353
+ pdf.addImage(imgData, 'PNG', 0, position, innerWidth, imgHeight)
354
+ heightLeft -= pdfHeight
355
+ }
356
+
357
+ pdf.save(`chat-answer-${idx + 1}.pdf`)
358
+ setExportingIndex(null)
359
+ } catch (error) {
360
+ console.error('PDF Export failed:', error)
361
+ setExportingIndex(null)
362
+ }
363
+ }, 100)
364
+ }
365
+
366
+ if (messages.length === 0) {
367
+ return (
368
+ <div className="welcome-screen">
369
+ <div id="tour-chat-interface" className="tour-chat-spotlight"></div>
370
+ <div className="welcome-icon">
371
+ <img src="/calculus-icon.png" alt="Icon" onError={(e) => e.target.style.display = 'none'} />
372
+ <div className="p-4 bg-indigo-100 rounded-full dark:bg-indigo-900/30" style={{ display: 'none' }}>
373
+ <Bot size={48} className="text-indigo-600 dark:text-indigo-400" />
374
+ </div>
375
+ </div>
376
+ <h2>Xin chào, tôi có thể giúp gì?</h2>
377
+ <p>Tôi là trợ lý AI chuyên về giải tích và toán học.<br />Hãy gửi bài toán hoặc câu hỏi của bạn để bắt đầu.</p>
378
+ <div className="example-prompts">
379
+ <button onClick={() => onExampleClick('Tính đạo hàm của hàm số y = x³ - 3x + 2')}>Tính đạo hàm của hàm số y = x³ - 3x + 2</button>
380
+ <button onClick={() => onExampleClick('Tính tích phân của hàm số f(x) = sin(x) từ 0 đến π')}>Tính tích phân của hàm số f(x) = sin(x) từ 0 đến π</button>
381
+ <button onClick={() => onExampleClick('Tìm cực trị của hàm số y = x⁴ - 2x²')}>Tìm cực trị của hàm số y = x⁴ - 2x²</button>
382
+ </div>
383
+ </div>
384
+ )
385
+ }
386
+
387
+ return (
388
+ <div className="messages-section">
389
+ <div id="tour-chat-interface" className="tour-chat-spotlight"></div>
390
+ <div
391
+ className={`messages-container ${isTransitioning ? 'transitioning' : ''}`}
392
+ ref={containerRef}
393
+ onScroll={handleScroll}
394
+ >
395
+ {messages.map((msg, idx) => (
396
+ <div
397
+ key={`${idx}-${msg.role}`}
398
+ className={`message ${msg.role} ${msg.isStreaming ? 'streaming' : ''} ${isRestored && idx >= prevMessagesLengthRef.current - 1 ? 'animate-msg-slide-up' : ''}`}
399
+ style={{ animationDelay: isRestored ? `${Math.min((idx - (prevMessagesLengthRef.current - 1)) * 0.05, 0.5)}s` : '0s' }}
400
+ >
401
+ {/* Avatar removed */}
402
+
403
+ <div className="message-body">
404
+ {msg.status && <div className="agent-status-badge">{msg.status}</div>}
405
+ {msg.images && msg.images.length > 0 && (
406
+ <div className="message-images-list">
407
+ {msg.images.map((src, i) => (
408
+ <div key={i} className="message-image-preview" onClick={() => onImageClick(msg.images, i)}>
409
+ <img src={src} alt={`Attachment ${i}`} />
410
+ </div>
411
+ ))}
412
+ </div>
413
+ )}
414
+
415
+ {msg.content ? (
416
+ <CollapsibleContent
417
+ content={msg.content}
418
+ messageId={conversationId ? `expand_${conversationId}_${idx}` : null}
419
+ isStreaming={msg.isStreaming}
420
+ >
421
+ {({ isOverflow, isExpanded, onToggleExpand }) => (
422
+ <MessageFooter
423
+ role={msg.role}
424
+ isOverflow={isOverflow}
425
+ isExpanded={isExpanded}
426
+ onToggleExpand={onToggleExpand}
427
+ onCopy={() => copyToClipboard(msg.content, idx)}
428
+ onExportMD={() => exportToMarkdown(msg.content, idx)}
429
+ onExportPDF={() => exportToPDF(idx)}
430
+ onExportLaTeX={() => exportToLaTeX(msg.content, idx)}
431
+ copiedId={copiedId}
432
+ idx={idx}
433
+ />
434
+ )}
435
+ </CollapsibleContent>
436
+ ) : msg.role === 'assistant' ? (
437
+ <div className="thinking-indicator"><span></span><span></span><span></span>Đang suy nghĩ...</div>
438
+ ) : (
439
+ <span className="text-gray-400 italic">...</span>
440
+ )}
441
+ </div>
442
+ </div>
443
+ ))}
444
+ <div ref={messagesEndRef} />
445
+ </div>
446
+
447
+ {/* Hidden Premium PDF Layout Component */}
448
+ {exportingIndex !== null && (
449
+ <div className="pdf-export-container" ref={pdfExportRef}>
450
+ {/* Background Watermark */}
451
+ <div className="pdf-watermark">POCHI</div>
452
+
453
+ <div className="pdf-header">
454
+ <div className="pdf-brand">POCHI</div>
455
+ <div className="pdf-meta">
456
+ <div>Assistant Export</div>
457
+ <div>{new Date().toLocaleDateString('vi-VN')}</div>
458
+ </div>
459
+ </div>
460
+ <div className="pdf-content">
461
+ <ReactMarkdown
462
+ remarkPlugins={[remarkMath, remarkGfm]}
463
+ rehypePlugins={[[rehypeKatex, { strict: false, trust: true, throwOnError: false }]]}
464
+ >
465
+ {preprocessLaTeX(parseMessageContent(messages[exportingIndex].content))}
466
+ </ReactMarkdown>
467
+ </div>
468
+ <div className="pdf-footer">
469
+ Tài liệu được tạo bởi Pochi
470
+ </div>
471
+ </div>
472
+ )}
473
+
474
+ {showScrollBtn && (
475
+ <button className="scroll-to-bottom" onClick={() => scrollToBottom('smooth')}>
476
+ <ChevronDown size={20} />
477
+ </button>
478
+ )}
479
+ </div>
480
+ )
481
+ }
482
+
483
+ export default MessageList
frontend/src/components/Modals.jsx ADDED
@@ -0,0 +1,596 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useMemo } from 'react'
2
+ import { Search, X, MessageSquare, Clock, ChevronLeft, ChevronRight, ZoomIn, ZoomOut, Maximize, Download, RotateCcw, Settings, User, Sun, Edit, Archive } from 'lucide-react'
3
+
4
+ export const SearchModal = ({ onSelect, onClose, conversations = [], isRestored = false }) => {
5
+ // Lock in the initial isRestored value to prevent re-triggering animations if App re-renders
6
+ const [wasRestored] = useState(isRestored)
7
+
8
+ const [query, setQuery] = useState(() => {
9
+ return sessionStorage.getItem('searchQuery') || ''
10
+ })
11
+ const [results, setResults] = useState([])
12
+ const [isLoading, setIsLoading] = useState(false)
13
+ const [debouncedQuery, setDebouncedQuery] = useState('')
14
+
15
+ useEffect(() => {
16
+ sessionStorage.setItem('searchQuery', query)
17
+ const timer = setTimeout(() => setDebouncedQuery(query), 300)
18
+ return () => clearTimeout(timer)
19
+ }, [query])
20
+
21
+ useEffect(() => {
22
+ if (!debouncedQuery || debouncedQuery.trim().length < 1) {
23
+ setResults([])
24
+ return
25
+ }
26
+ const fetchResults = async () => {
27
+ setIsLoading(true)
28
+ try {
29
+ const res = await fetch(`http://localhost:7860/api/search?q=${encodeURIComponent(debouncedQuery)}`)
30
+ if (res.ok) {
31
+ const data = await res.json()
32
+ setResults(data)
33
+ }
34
+ } catch (error) {
35
+ console.error("Search failed", error)
36
+ } finally {
37
+ setIsLoading(false)
38
+ }
39
+ }
40
+ fetchResults()
41
+ }, [debouncedQuery])
42
+
43
+ const groupedConversations = React.useMemo(() => {
44
+ if (query.trim().length >= 1) return null;
45
+ const groups = { today: [], yesterday: [], last7Days: [], older: [] }
46
+ const now = new Date()
47
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
48
+ const yesterday = new Date(today)
49
+ yesterday.setDate(yesterday.getDate() - 1)
50
+ const last7Days = new Date(today)
51
+ last7Days.setDate(last7Days.getDate() - 7)
52
+
53
+ conversations.forEach(conv => {
54
+ const dateStr = conv.updated_at || conv.created_at
55
+ if (!dateStr) { groups.older.push(conv); return; }
56
+ const date = new Date(dateStr)
57
+ if (date >= today) groups.today.push(conv)
58
+ else if (date >= yesterday) groups.yesterday.push(conv)
59
+ else if (date >= last7Days) groups.last7Days.push(conv)
60
+ else groups.older.push(conv)
61
+ })
62
+ return groups
63
+ }, [conversations, query])
64
+
65
+ useEffect(() => {
66
+ const handleEsc = (e) => { if (e.key === 'Escape') onClose() }
67
+ window.addEventListener('keydown', handleEsc)
68
+ return () => window.removeEventListener('keydown', handleEsc)
69
+ }, [onClose])
70
+
71
+ const highlightText = (text, highlight) => {
72
+ if (!highlight) return text
73
+
74
+ // Function to strip Vietnamese accents for matching
75
+ const stripAccents = (str) => {
76
+ return str.normalize('NFD')
77
+ .replace(/[\u0300-\u036f]/g, "")
78
+ .replace(/đ/g, "d")
79
+ .replace(/Đ/g, "D");
80
+ }
81
+
82
+ const normalizedText = stripAccents(text.toLowerCase())
83
+ const normalizedQuery = stripAccents(highlight.toLowerCase())
84
+
85
+ if (!normalizedText.includes(normalizedQuery)) return text
86
+
87
+ const parts = []
88
+ let lastIdx = 0
89
+ let idx = normalizedText.indexOf(normalizedQuery)
90
+
91
+ while (idx !== -1) {
92
+ // Push non-matching part
93
+ parts.push(text.substring(lastIdx, idx))
94
+ // Push matching part with original formatting
95
+ const matchedContent = text.substring(idx, idx + highlight.length)
96
+ parts.push(
97
+ <span key={idx} className="bg-brand-primary/20 text-brand-primary rounded px-0.5">
98
+ {text.substring(idx, idx + highlight.length)}
99
+ </span>
100
+ )
101
+ lastIdx = idx + highlight.length
102
+ idx = normalizedText.indexOf(normalizedQuery, lastIdx)
103
+ }
104
+ parts.push(text.substring(lastIdx))
105
+
106
+ return parts
107
+ }
108
+
109
+ const renderGroup = (title, items) => {
110
+ if (!items || items.length === 0) return null
111
+ return (
112
+ <div className="search-group">
113
+ <h3 className="group-title">{title}</h3>
114
+ <div className="group-items">
115
+ {items.map(conv => (
116
+ <button
117
+ key={conv.id}
118
+ onClick={() => { onSelect(conv.id); onClose(); }}
119
+ className="search-result-item"
120
+ >
121
+ <div className="item-icon">
122
+ <MessageSquare size={14} />
123
+ </div>
124
+ <span className="item-title truncate">{conv.title || 'Đoạn chat mới'}</span>
125
+ </button>
126
+ ))}
127
+ </div>
128
+ </div>
129
+ )
130
+ }
131
+
132
+ return (
133
+ <div className={`modal-overlay centered ${wasRestored ? 'no-animation' : ''}`} onClick={onClose}>
134
+ <div className={`search-modal-container glass ${wasRestored ? 'no-animation' : ''}`} onClick={e => e.stopPropagation()}>
135
+ <div className="search-modal-header">
136
+ <Search className="w-5 h-5 text-text-tertiary" />
137
+ <input
138
+ type="text"
139
+ placeholder="Tìm kiếm tin nhắn, hội thoại..."
140
+ className="search-input"
141
+ value={query}
142
+ onChange={e => setQuery(e.target.value)}
143
+ autoFocus
144
+ />
145
+ <div className="search-shortcut">ESC</div>
146
+ </div>
147
+
148
+ <div className="search-modal-content custom-scrollbar">
149
+ {query.trim().length >= 1 ? (
150
+ <div className="p-2">
151
+ {results.length > 0 ? (
152
+ <div className="flex flex-col gap-1">
153
+ {results.map((item) => (
154
+ <button
155
+ key={`${item.type}-${item.id}`}
156
+ onClick={() => { onSelect(item.conversation_id || item.id); onClose(); }}
157
+ className="search-result-item"
158
+ >
159
+ <div className="item-icon">
160
+ {item.type === 'conversation' ? <MessageSquare size={14} /> : <Search size={14} />}
161
+ </div>
162
+ <div className="flex-1 min-w-0">
163
+ <div className="flex items-center justify-between gap-2">
164
+ <span className="item-title font-medium text-sm truncate">{highlightText(item.title, debouncedQuery)}</span>
165
+ <span className="item-date text-[10px] opacity-40 uppercase">{new Date(item.created_at).toLocaleDateString()}</span>
166
+ </div>
167
+ {item.content && (
168
+ <p className="text-xs opacity-60 line-clamp-1 mt-0.5">{highlightText(item.content, debouncedQuery)}</p>
169
+ )}
170
+ </div>
171
+ </button>
172
+ ))}
173
+ </div>
174
+ ) : (!isLoading && query.trim() === debouncedQuery.trim()) ? (
175
+ <div className="empty-state">Không tìm thấy kết quả nào cho "{query}"</div>
176
+ ) : null}
177
+ </div>
178
+ ) : (
179
+ <div className="py-2">
180
+ {groupedConversations && Object.entries(groupedConversations).map(([key, items]) => {
181
+ const labels = { today: 'Hôm nay', yesterday: 'Hôm qua', last7Days: '7 ngày qua', older: 'Cũ hơn' }
182
+ return renderGroup(labels[key], items)
183
+ })}
184
+ </div>
185
+ )}
186
+ </div>
187
+ </div>
188
+ </div>
189
+ )
190
+ }
191
+
192
+ export const SettingsModal = ({
193
+ onClose,
194
+ darkMode,
195
+ onToggleTheme,
196
+ archivedSessions = [],
197
+ onRestoreSession,
198
+ onUpdateProfile,
199
+ userProfile,
200
+ initialTab = 'general',
201
+ isRestored = false,
202
+ onTabChange
203
+ }) => {
204
+ // Lock in the initial isRestored value to prevent re-triggering animations if App re-renders
205
+ const [wasRestored] = useState(isRestored)
206
+
207
+ const [activeTab, setActiveTab] = useState(initialTab)
208
+ const [editName, setEditName] = useState(userProfile.name)
209
+ const [editEmail, setEditEmail] = useState(userProfile.email)
210
+ const [editAvatar, setEditAvatar] = useState(userProfile.avatar)
211
+ const [isSaving, setIsSaving] = useState(false)
212
+ const [showUnsavedWarning, setShowUnsavedWarning] = useState(false)
213
+ const [pendingAction, setPendingAction] = useState(null) // { type: 'tab', value: '...' } or { type: 'close' }
214
+
215
+ const tabs = [
216
+ { id: 'general', label: 'Chung', icon: Settings },
217
+ { id: 'account', label: 'Tài khoản', icon: User },
218
+ { id: 'appearance', label: 'Giao diện', icon: Sun },
219
+ { id: 'archived', label: 'Lưu trữ', icon: Archive }
220
+ ]
221
+
222
+ const hasUnsavedChanges = useMemo(() => {
223
+ return editName !== userProfile.name ||
224
+ editEmail !== userProfile.email ||
225
+ editAvatar !== userProfile.avatar
226
+ }, [editName, editEmail, editAvatar, userProfile])
227
+
228
+ const handleTabClick = (tabId) => {
229
+ if (activeTab === 'account' && hasUnsavedChanges) {
230
+ setPendingAction({ type: 'tab', value: tabId })
231
+ setShowUnsavedWarning(true)
232
+ } else {
233
+ setActiveTab(tabId)
234
+ if (onTabChange) onTabChange(tabId)
235
+ }
236
+ }
237
+
238
+ const handleCloseClick = () => {
239
+ if (activeTab === 'account' && hasUnsavedChanges) {
240
+ setPendingAction({ type: 'close' })
241
+ setShowUnsavedWarning(true)
242
+ } else {
243
+ onClose()
244
+ }
245
+ }
246
+
247
+ const handleDiscard = () => {
248
+ // Reset local state to original
249
+ setEditName(userProfile.name)
250
+ setEditEmail(userProfile.email)
251
+ setEditAvatar(userProfile.avatar)
252
+ setShowUnsavedWarning(false)
253
+
254
+ if (pendingAction.type === 'tab') {
255
+ setActiveTab(pendingAction.value)
256
+ if (onTabChange) onTabChange(pendingAction.value)
257
+ } else if (pendingAction.type === 'close') {
258
+ onClose()
259
+ }
260
+ }
261
+
262
+ const handleSaveAndContinue = async () => {
263
+ setIsSaving(true)
264
+ await onUpdateProfile({ name: editName, email: editEmail, avatar: editAvatar })
265
+ setIsSaving(false)
266
+ setShowUnsavedWarning(false)
267
+
268
+ if (pendingAction.type === 'tab') {
269
+ setActiveTab(pendingAction.value)
270
+ } else if (pendingAction.type === 'close') {
271
+ onClose()
272
+ }
273
+ }
274
+
275
+ return (
276
+ <div className={`modal-overlay centered ${wasRestored ? 'no-animation' : ''}`} onClick={handleCloseClick}>
277
+ <div className={`settings-container glass ${wasRestored ? 'no-animation' : ''}`} onClick={e => e.stopPropagation()}>
278
+ {/* Sidebar Tabs */}
279
+ <div className="settings-nav">
280
+ <div className="nav-title">Cài đặt</div>
281
+ <div className="nav-items">
282
+ {tabs.map(tab => (
283
+ <button
284
+ key={tab.id}
285
+ className={`nav-tab ${activeTab === tab.id ? 'active' : ''}`}
286
+ onClick={() => handleTabClick(tab.id)}
287
+ >
288
+ <tab.icon size={18} />
289
+ <span>{tab.label}</span>
290
+ </button>
291
+ ))}
292
+ </div>
293
+ </div>
294
+
295
+ <button className="body-close" onClick={handleCloseClick}><X size={20} /></button>
296
+
297
+ {/* Main Content */}
298
+ <div className="settings-body">
299
+
300
+ <div className="body-content custom-scrollbar">
301
+ <header className="content-header">
302
+ <h2>{tabs.find(t => t.id === activeTab).label}</h2>
303
+ </header>
304
+
305
+ {activeTab === 'general' && (
306
+ <div className="content-section">
307
+ <section className="setting-group">
308
+ <div className="group-info">
309
+ <h3>Dữ liệu huấn luyện</h3>
310
+ <p>Đóng góp các cuộc hội thoại để cải thiện mô hình AI cho mọi người.</p>
311
+ </div>
312
+ <div className="group-action">
313
+ <button className="toggle-pill on">Bật</button>
314
+ </div>
315
+ </section>
316
+ <section className="setting-group">
317
+ <div className="group-info">
318
+ <h3>Lưu trữ hội thoại</h3>
319
+ <p>Tự động lưu lịch sử chat vào tài khoản của bạn.</p>
320
+ </div>
321
+ <div className="group-action">
322
+ <button className="toggle-pill on">Bật</button>
323
+ </div>
324
+ </section>
325
+ </div>
326
+ )}
327
+
328
+ {activeTab === 'account' && (
329
+ <div className="content-section">
330
+ <div className="user-profile-card editing">
331
+ <div className="card-avatar large">
332
+ {editAvatar ? <img src={editAvatar} alt="Avatar" /> : <div className="avatar-placeholder large">{editName.charAt(0)}</div>}
333
+ <label className="avatar-edit-overlay">
334
+ <Edit size={16} />
335
+ <span>Thay đổi</span>
336
+ <input type="file" hidden accept="image/*" onChange={e => {
337
+ const file = e.target.files[0]
338
+ if (file) {
339
+ const reader = new FileReader()
340
+ reader.onloadend = () => {
341
+ setEditAvatar(reader.result)
342
+ }
343
+ reader.readAsDataURL(file)
344
+ }
345
+ }} />
346
+ </label>
347
+ </div>
348
+ <div className="card-form">
349
+ <div className="form-group">
350
+ <label>Họ và tên</label>
351
+ <input
352
+ type="text"
353
+ value={editName}
354
+ onChange={e => setEditName(e.target.value)}
355
+ placeholder="Nhập tên của bạn"
356
+ />
357
+ </div>
358
+ <div className="form-group">
359
+ <label>Email</label>
360
+ <input
361
+ type="email"
362
+ value={editEmail}
363
+ onChange={e => setEditEmail(e.target.value)}
364
+ placeholder="social@example.com"
365
+ />
366
+ </div>
367
+ <button
368
+ className={`save-profile-btn ${hasUnsavedChanges ? 'active' : ''}`}
369
+ disabled={isSaving || !hasUnsavedChanges}
370
+ onClick={() => {
371
+ setIsSaving(true)
372
+ onUpdateProfile({ name: editName, email: editEmail, avatar: editAvatar })
373
+ setTimeout(() => setIsSaving(false), 500)
374
+ }}
375
+ >
376
+ {isSaving ? 'Đang lưu...' : 'Lưu thay đổi'}
377
+ </button>
378
+ </div>
379
+ </div>
380
+ <div className="account-danger-zone">
381
+ <button className="danger-btn-outline">Đăng xuất khỏi tất cả các thiết bị</button>
382
+ <button className="danger-text">Xóa tài khoản</button>
383
+ </div>
384
+ </div>
385
+ )}
386
+
387
+ {activeTab === 'appearance' && (
388
+ <div className="content-section">
389
+ <div className="appearance-grid">
390
+ <button
391
+ className={`theme-card ${!darkMode ? 'active' : ''}`}
392
+ onClick={() => !darkMode || onToggleTheme()}
393
+ >
394
+ <div className="theme-preview light" />
395
+ <span>Giao diện sáng</span>
396
+ </button>
397
+ <button
398
+ className={`theme-card ${darkMode ? 'active' : ''}`}
399
+ onClick={() => darkMode || onToggleTheme()}
400
+ >
401
+ <div className="theme-preview dark" />
402
+ <span>Giao diện tối</span>
403
+ </button>
404
+ </div>
405
+ </div>
406
+ )}
407
+
408
+ {activeTab === 'archived' && (
409
+ <div className="content-section">
410
+ {archivedSessions.length > 0 ? (
411
+ <div className="archived-list">
412
+ {archivedSessions.map(session => (
413
+ <div key={session.id} className="archived-row">
414
+ <div className="row-info">
415
+ <span className="row-title">{session.title || 'Không có tiêu đề'}</span>
416
+ <span className="row-date">{new Date(session.created_at).toLocaleDateString()}</span>
417
+ </div>
418
+ <button
419
+ className="restore-btn"
420
+ onClick={() => onRestoreSession(session.id)}
421
+ title="Khôi phục"
422
+ >
423
+ <Archive size={14} />
424
+ Khôi phục
425
+ </button>
426
+ </div>
427
+ ))}
428
+ </div>
429
+ ) : (
430
+ <div className="empty-state">
431
+ <Clock size={40} className="mb-4 opacity-20" />
432
+ <p>Không có hội thoại nào được lưu trữ</p>
433
+ </div>
434
+ )}
435
+ </div>
436
+ )}
437
+ </div>
438
+ </div>
439
+
440
+ {/* Unsaved Changes Warning Overlay */}
441
+ {showUnsavedWarning && (
442
+ <div className="unsaved-warning-overlay glass">
443
+ <div className="warning-content">
444
+ <div className="warning-icon-wrapper">
445
+ <Clock size={28} className="text-amber-500 animate-pulse" />
446
+ </div>
447
+ <h3>Bạn chưa lưu thay đổi</h3>
448
+ <p>Các chỉnh sửa trong phần tài khoản của bạn chưa được lưu lại. Bạn muốn làm gì tiếp theo?</p>
449
+ <div className="warning-actions">
450
+ <button className="warning-btn discard" onClick={handleDiscard}>Bỏ qua</button>
451
+ <button className="warning-btn cancel" onClick={() => setShowUnsavedWarning(false)}>Quay lại</button>
452
+ <button className="warning-btn save" onClick={handleSaveAndContinue}>Lưu & Tiếp tục</button>
453
+ </div>
454
+ </div>
455
+ </div>
456
+ )}
457
+ </div>
458
+ </div>
459
+ )
460
+ }
461
+
462
+ // Helper needed for the above
463
+ const renderedGroups = (groups, renderer) => {
464
+ if (!groups) return null;
465
+ return (
466
+ <>
467
+ {renderer("Hôm nay", groups.today)}
468
+ {renderer("Hôm qua", groups.yesterday)}
469
+ {renderer("7 ngày trước", groups.last7Days)}
470
+ {renderer("Cũ hơn", groups.older)}
471
+ </>
472
+ )
473
+ }
474
+
475
+
476
+
477
+ export const ImageViewer = ({ images = [], startIndex = 0, onClose, onIndexChange, isRestored = false }) => {
478
+ // Lock in the initial isRestored value to prevent re-triggering animations if App re-renders
479
+ const [wasRestored] = useState(isRestored)
480
+
481
+ const [currentIndex, setCurrentIndex] = useState(startIndex)
482
+ const [zoom, setZoom] = useState(1)
483
+ const [rotation, setRotation] = useState(0)
484
+
485
+ // Sync current index to parent for persistence
486
+ useEffect(() => {
487
+ if (onIndexChange) onIndexChange(currentIndex)
488
+ }, [currentIndex, onIndexChange])
489
+
490
+ useEffect(() => {
491
+ const handleKeyDown = (e) => {
492
+ if (e.key === 'Escape') onClose()
493
+ if (e.key === 'ArrowLeft') handlePrev()
494
+ if (e.key === 'ArrowRight') handleNext()
495
+ }
496
+ window.addEventListener('keydown', handleKeyDown)
497
+ return () => window.removeEventListener('keydown', handleKeyDown)
498
+ }, [currentIndex, images.length])
499
+
500
+ const handleNext = () => {
501
+ setCurrentIndex((prev) => (prev + 1) % images.length)
502
+ setZoom(1)
503
+ setRotation(0)
504
+ }
505
+
506
+ const handlePrev = () => {
507
+ setCurrentIndex((prev) => (prev - 1 + images.length) % images.length)
508
+ setZoom(1)
509
+ setRotation(0)
510
+ }
511
+
512
+ const handleZoomIn = () => setZoom(prev => Math.min(prev + 0.25, 3))
513
+ const handleZoomOut = () => setZoom(prev => Math.max(prev - 0.25, 0.5))
514
+ const handleReset = () => {
515
+ setZoom(1)
516
+ setRotation(0)
517
+ }
518
+
519
+ const handleDownload = () => {
520
+ const link = document.createElement('a')
521
+ link.href = images[currentIndex]
522
+ link.download = `image-${currentIndex + 1}.png`
523
+ link.click()
524
+ }
525
+
526
+ if (!images || images.length === 0) return null
527
+
528
+ return (
529
+ <div className={`premium-viewer-overlay ${wasRestored ? 'is-restored' : ''}`} onClick={onClose}>
530
+ {/* Top Controls */}
531
+ <div className="viewer-top-bar" onClick={e => e.stopPropagation()}>
532
+ <div className="viewer-info">
533
+ {currentIndex + 1} / {images.length}
534
+ </div>
535
+ <div className="viewer-actions">
536
+ <button onClick={handleZoomOut} title="Thu nhỏ"><ZoomOut size={20} /></button>
537
+ <button onClick={handleZoomIn} title="Phóng to"><ZoomIn size={20} /></button>
538
+ <button onClick={handleReset} title="Đặt lại"><RotateCcw size={20} /></button>
539
+ <div className="viewer-divider"></div>
540
+ <button onClick={handleDownload} title="Tải về"><Download size={20} /></button>
541
+ <button onClick={onClose} className="viewer-close-btn" title="Đóng (Esc)"><X size={20} /></button>
542
+ </div>
543
+ </div>
544
+
545
+ {/* Navigation Arrows */}
546
+ {images.length > 1 && (
547
+ <>
548
+ <button
549
+ className="viewer-nav-btn prev"
550
+ onClick={(e) => { e.stopPropagation(); handlePrev(); }}
551
+ >
552
+ <ChevronLeft size={32} />
553
+ </button>
554
+ <button
555
+ className="viewer-nav-btn next"
556
+ onClick={(e) => { e.stopPropagation(); handleNext(); }}
557
+ >
558
+ <ChevronRight size={32} />
559
+ </button>
560
+ </>
561
+ )}
562
+
563
+ {/* Image Container */}
564
+ <div className="viewer-image-container" onClick={e => e.stopPropagation()}>
565
+ <img
566
+ src={images[currentIndex]}
567
+ alt={`Preview ${currentIndex + 1}`}
568
+ style={{
569
+ transform: `scale(${zoom}) rotate(${rotation}deg)`,
570
+ transition: zoom === 1 ? 'transform 0.3s cubic-bezier(0.16, 1, 0.3, 1)' : 'none'
571
+ }}
572
+ className="viewer-main-image"
573
+ />
574
+ </div>
575
+
576
+ {/* Thumbnails Strip */}
577
+ {images.length > 1 && (
578
+ <div className="viewer-thumbnails-strip" onClick={e => e.stopPropagation()}>
579
+ {images.map((img, idx) => (
580
+ <div
581
+ key={idx}
582
+ className={`viewer-thumb-item ${idx === currentIndex ? 'active' : ''}`}
583
+ onClick={() => {
584
+ setCurrentIndex(idx)
585
+ setZoom(1)
586
+ setRotation(0)
587
+ }}
588
+ >
589
+ <img src={img} alt={`Thumb ${idx}`} />
590
+ </div>
591
+ ))}
592
+ </div>
593
+ )}
594
+ </div>
595
+ )
596
+ }
frontend/src/components/Sidebar.jsx ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef } from 'react'
2
+ import {
3
+ ChevronLeft,
4
+ ChevronDown,
5
+ ChevronRight,
6
+ Search,
7
+ MessageSquare,
8
+ Edit,
9
+ Image,
10
+ LayoutGrid,
11
+ Folder,
12
+ Settings,
13
+ User,
14
+ Pin,
15
+ MoreHorizontal,
16
+ Archive,
17
+ Trash2,
18
+ PanelLeft,
19
+ PanelRight
20
+ } from 'lucide-react'
21
+
22
+ const Sidebar = ({
23
+ isOpen,
24
+ toggleSidebar,
25
+ conversations = [],
26
+ currentConversationId,
27
+ onSelectConversation,
28
+ onNewChat,
29
+ onDeleteConversation,
30
+ onRenameConversation,
31
+ onTogglePin,
32
+ onToggleArchive,
33
+ onSearchClick,
34
+ onSettingsClick,
35
+ darkMode,
36
+ userProfile
37
+ }) => {
38
+ const sidebarRef = useRef(null)
39
+ const [activeMenuId, setActiveMenuId] = React.useState(() => {
40
+ return sessionStorage.getItem('activeMenuId') || null
41
+ })
42
+ const [isHistoryCollapsed, setIsHistoryCollapsed] = useState(() => {
43
+ return localStorage.getItem('isHistoryCollapsed') === 'true'
44
+ })
45
+ const [editingId, setEditingId] = useState(() => {
46
+ return sessionStorage.getItem('editingId') || null
47
+ })
48
+ const [editTitle, setEditTitle] = useState(() => {
49
+ return sessionStorage.getItem('editTitle') || ''
50
+ })
51
+ const [menuPlacement, setMenuPlacement] = useState(() => {
52
+ return sessionStorage.getItem('menuPlacement') || 'bottom'
53
+ })
54
+
55
+ const pinnedChats = conversations.filter(c => c.isPinned && !c.isArchived)
56
+ const activeChats = conversations.filter(c => !c.isPinned && !c.isArchived)
57
+
58
+ const handleStartRename = (conv) => {
59
+ setEditingId(conv.id)
60
+ setEditTitle(conv.title || 'Đoạn chat mới')
61
+ setActiveMenuId(null)
62
+ sessionStorage.setItem('isNewRename', 'true')
63
+ }
64
+
65
+ const handleSaveRename = (id) => {
66
+ if (editTitle.trim()) {
67
+ onRenameConversation(id, editTitle.trim())
68
+ }
69
+ setEditingId(null)
70
+ }
71
+
72
+ const handleCancelRename = () => {
73
+ setEditingId(null)
74
+ }
75
+
76
+ // Close menu when clicking outside
77
+ React.useEffect(() => {
78
+ const handleClickOutside = () => setActiveMenuId(null)
79
+ window.addEventListener('click', handleClickOutside)
80
+ return () => window.removeEventListener('click', handleClickOutside)
81
+ }, [])
82
+
83
+ useEffect(() => {
84
+ localStorage.setItem('isHistoryCollapsed', isHistoryCollapsed)
85
+ }, [isHistoryCollapsed])
86
+
87
+ useEffect(() => {
88
+ if (activeMenuId) {
89
+ sessionStorage.setItem('activeMenuId', activeMenuId)
90
+ sessionStorage.setItem('menuPlacement', menuPlacement)
91
+ } else {
92
+ sessionStorage.removeItem('activeMenuId')
93
+ sessionStorage.removeItem('menuPlacement')
94
+ }
95
+ }, [activeMenuId, menuPlacement])
96
+
97
+ useEffect(() => {
98
+ if (editingId) {
99
+ sessionStorage.setItem('editingId', editingId)
100
+ sessionStorage.setItem('editTitle', editTitle)
101
+ } else {
102
+ sessionStorage.removeItem('editingId')
103
+ sessionStorage.removeItem('editTitle')
104
+ }
105
+ }, [editingId, editTitle])
106
+
107
+ const historyScrollRef = useRef(null)
108
+
109
+ // Handle history scroll persistence
110
+ const handleHistoryScroll = (e) => {
111
+ if (!isOpen) return // Only save if open
112
+ sessionStorage.setItem('sidebarScrollTop', e.target.scrollTop)
113
+ }
114
+
115
+ React.useLayoutEffect(() => {
116
+ if (!isHistoryCollapsed && historyScrollRef.current) {
117
+ const savedScroll = sessionStorage.getItem('sidebarScrollTop')
118
+ if (savedScroll) {
119
+ historyScrollRef.current.scrollTop = parseInt(savedScroll, 10)
120
+ }
121
+ }
122
+ }, [isHistoryCollapsed, conversations]) // Restore when expanded or conversations change
123
+
124
+ const renderChatItem = (conv) => (
125
+ <div
126
+ key={conv.id}
127
+ className={`sidebar-chat-item group ${conv.id === currentConversationId ? 'active' : ''} ${activeMenuId === conv.id ? 'menu-open' : ''} ${editingId === conv.id ? 'editing' : ''}`}
128
+ onClick={() => onSelectConversation(conv.id)}
129
+ >
130
+ <div className="active-indicator" />
131
+
132
+ {editingId === conv.id ? (
133
+ <div className="chat-item-text editing" onClick={e => e.stopPropagation()}>
134
+ <input
135
+ className="inline-rename-input"
136
+ value={editTitle}
137
+ onChange={(e) => setEditTitle(e.target.value)}
138
+ onBlur={() => handleSaveRename(conv.id)}
139
+ onKeyDown={(e) => {
140
+ if (e.key === 'Enter') handleSaveRename(conv.id)
141
+ if (e.key === 'Escape') handleCancelRename()
142
+ }}
143
+ autoFocus
144
+ spellCheck="false"
145
+ autoComplete="off"
146
+ onFocus={e => {
147
+ // Only select all if this is NOT a restored state from refresh
148
+ const isRestored = !sessionStorage.getItem('isNewRename');
149
+ if (!isRestored) {
150
+ e.target.select();
151
+ sessionStorage.removeItem('isNewRename');
152
+ }
153
+ }}
154
+ />
155
+ </div>
156
+ ) : (
157
+ <div className="chat-item-text truncate">
158
+ {conv.isPinned && <Pin size={12} className="chat-pin-icon" fill="currentColor" />}
159
+ <span>{conv.title || 'Đoạn chat mới'}</span>
160
+ </div>
161
+ )}
162
+
163
+ {/* Chat Actions Menu */}
164
+ {!editingId && (
165
+ <div className="chat-item-actions">
166
+ <button
167
+ onClick={(e) => {
168
+ e.stopPropagation();
169
+ const rect = e.currentTarget.getBoundingClientRect();
170
+ const spaceBelow = window.innerHeight - rect.bottom;
171
+ const newPlacement = spaceBelow < 200 ? 'top' : 'bottom';
172
+ setMenuPlacement(newPlacement);
173
+ setActiveMenuId(activeMenuId === conv.id ? null : conv.id)
174
+ }}
175
+ className="menu-trigger-btn"
176
+ >
177
+ <MoreHorizontal size={15.5} />
178
+ </button>
179
+
180
+ {activeMenuId === conv.id && (
181
+ <div className={`chat-menu-dropdown glass placement-${menuPlacement}`} onClick={e => e.stopPropagation()}>
182
+ <button className="menu-item" onClick={() => handleStartRename(conv)}>
183
+ <Edit size={15} />
184
+ <span>Đổi tên</span>
185
+ </button>
186
+ <button className="menu-item" onClick={() => { onTogglePin(conv.id); setActiveMenuId(null); }}>
187
+ <Pin size={15} fill={conv.isPinned ? "currentColor" : "none"} />
188
+ <span>{conv.isPinned ? "Bỏ ghim" : "Ghim"}</span>
189
+ </button>
190
+ <button className="menu-item" onClick={() => { onToggleArchive(conv.id); setActiveMenuId(null); }}>
191
+ <Archive size={15} />
192
+ <span>Lưu trữ</span>
193
+ </button>
194
+ <div className="menu-separator" />
195
+ <button className="menu-item delete" onClick={() => { onDeleteConversation(conv.id); setActiveMenuId(null); }}>
196
+ <Trash2 size={15} />
197
+ <span>Xóa</span>
198
+ </button>
199
+ </div>
200
+ )}
201
+ </div>
202
+ )}
203
+ </div>
204
+ )
205
+
206
+ return (
207
+ <div className={`sidebar-container ${isOpen ? 'open' : 'closed'}`}>
208
+ <div className="sidebar-inner">
209
+ {/* Header Actions */}
210
+ <div className="sidebar-top-nav">
211
+ <div className="nav-header">
212
+ <div className="sidebar-brand">
213
+ <img src="/pochi.jpeg" alt="Pochi Logo" className="brand-logo" id="tour-logo" />
214
+ </div>
215
+ {isOpen && (
216
+ <button
217
+ className="toggle-sidebar-trigger"
218
+ onClick={toggleSidebar}
219
+ title="Đóng sidebar"
220
+ id="tour-toggle-sidebar"
221
+ >
222
+ <PanelLeft size={20} />
223
+ </button>
224
+ )}
225
+ </div>
226
+
227
+ <div className="nav-list">
228
+ <button className="nav-item new-chat-btn" onClick={onNewChat} id="tour-new-chat">
229
+ <div className="nav-item-icon">
230
+ <Edit size={18} />
231
+ </div>
232
+ <span>Đoạn chat mới</span>
233
+ {isOpen && (
234
+ <div className="shortcut-hint">
235
+ {navigator.platform.toUpperCase().indexOf('MAC') >= 0 ? '⌘E' : 'Ctrl E'}
236
+ </div>
237
+ )}
238
+ </button>
239
+
240
+ <button className="nav-item search-btn" onClick={onSearchClick} id="tour-search">
241
+ <div className="nav-item-icon">
242
+ <Search size={18} />
243
+ </div>
244
+ <span>Tìm kiếm đoạn chat</span>
245
+ {isOpen && (
246
+ <div className="shortcut-hint">
247
+ {navigator.platform.toUpperCase().indexOf('MAC') >= 0 ? '⌘F' : 'Ctrl F'}
248
+ </div>
249
+ )}
250
+ </button>
251
+
252
+ <div className="nav-item-pill disabled">
253
+ <div className="nav-item-icon">
254
+ <Image size={18} />
255
+ </div>
256
+ <span>Ảnh <span className="badge-new">MỚI</span></span>
257
+ </div>
258
+
259
+ <div className="nav-item-pill disabled">
260
+ <div className="nav-item-icon">
261
+ <LayoutGrid size={18} />
262
+ </div>
263
+ <span>Ứng dụng</span>
264
+ </div>
265
+
266
+ <div className="nav-item-pill disabled">
267
+ <div className="nav-item-icon">
268
+ <Folder size={18} />
269
+ </div>
270
+ <span>Dự án</span>
271
+ </div>
272
+ </div>
273
+ </div>
274
+
275
+ <div className="sidebar-history" id="tour-history-section">
276
+ {/* Clickable area when sidebar is closed - from "Dự án" down to footer (but not including footer) */}
277
+ {!isOpen && (
278
+ <div
279
+ className="sidebar-clickable-area"
280
+ onClick={toggleSidebar}
281
+ />
282
+ )}
283
+ <div
284
+ className="history-label collapsible"
285
+ onClick={() => setIsHistoryCollapsed(!isHistoryCollapsed)}
286
+ id="tour-history-label"
287
+ >
288
+ <span>Các đoạn chat của bạn</span>
289
+ {isHistoryCollapsed ? <ChevronRight size={15} /> : <ChevronDown size={15} />}
290
+ </div>
291
+
292
+ {!isHistoryCollapsed && (
293
+ <div
294
+ className="history-scroll-area custom-scrollbar"
295
+ ref={historyScrollRef}
296
+ onScroll={handleHistoryScroll}
297
+ >
298
+ {pinnedChats.length > 0 && (
299
+ <div className="history-section">
300
+ {pinnedChats.map(renderChatItem)}
301
+ </div>
302
+ )}
303
+
304
+ <div className="history-section">
305
+ {activeChats.length > 0 ? (
306
+ activeChats.map(renderChatItem)
307
+ ) : (
308
+ <div className="history-empty">Chưa có cuộc trò chuyện nào</div>
309
+ )}
310
+ </div>
311
+ </div>
312
+ )}
313
+ </div>
314
+
315
+ <div className="sidebar-footer" id="tour-profile">
316
+ <div className="profile-section" onClick={() => onSettingsClick('account')}>
317
+ <div className="profile-avatar" id="tour-profile-sidebar-avatar">
318
+ {userProfile?.avatar ? (
319
+ <img src={userProfile.avatar} alt="Avatar" className="sidebar-avatar-img" />
320
+ ) : (
321
+ <div className="avatar-circle">{userProfile?.name?.charAt(0) || 'U'}</div>
322
+ )}
323
+ </div>
324
+ <div className="profile-info">
325
+ <div className="profile-name">{userProfile?.name || 'Vô danh'}</div>
326
+ <div className="profile-plan">Free</div>
327
+ </div>
328
+ <button className="upgrade-btn" onClick={(e) => { e.stopPropagation(); /* handle upgrade */ }}>
329
+ Nâng cấp
330
+ </button>
331
+ </div>
332
+ </div>
333
+ </div>
334
+ </div>
335
+ )
336
+ }
337
+
338
+ export default Sidebar
frontend/src/index.css ADDED
@@ -0,0 +1,426 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
2
+
3
+ @tailwind base;
4
+ @tailwind components;
5
+ @tailwind utilities;
6
+
7
+ /* KaTeX styles */
8
+ @import 'katex/dist/katex.min.css';
9
+
10
+ /* =========================================
11
+ DESIGN SYSTEM & TOKENS
12
+ ========================================= */
13
+ :root {
14
+ /* --- LIGHT THEME (Clean, Airy, Professional) --- */
15
+
16
+ /* Brand Colors - Indigo/Violet fused for modern tech feel */
17
+ --brand-primary: #6366f1;
18
+ --brand-primary-rgb: 99, 102, 241;
19
+ /* Indigo 500 */
20
+ --brand-hover: #4f46e5;
21
+ /* Indigo 600 */
22
+ --brand-light: #e0e7ff;
23
+ /* Indigo 100 */
24
+ --brand-text: #ffffff;
25
+
26
+ /* Backgrounds */
27
+ --bg-canvas: #ffffff;
28
+ --bg-canvas-rgb: 255, 255, 255;
29
+ --bg-surface: #f8fafc;
30
+ --bg-surface-rgb: 248, 250, 252;
31
+ /* Slate 50 */
32
+ --bg-surface-hover: #eaeff3;
33
+ /* Balanced hover: slightly darker than Slate 100, lighter than Slate 200 */
34
+ --bg-modal: rgba(255, 255, 255, 0.95);
35
+
36
+ /* Text */
37
+ --text-primary: #0f172a;
38
+ /* Slate 900 */
39
+ --text-secondary: #64748b;
40
+ /* Slate 500 */
41
+ --text-tertiary: #94a3b8;
42
+ /* Slate 400 */
43
+
44
+ /* Borders */
45
+ --border-light: #e2e8f0;
46
+ /* Slate 200 */
47
+ --border-medium: #cbd5e1;
48
+ /* Slate 300 */
49
+
50
+ /* Status */
51
+ --status-success: #10b981;
52
+ --status-error: #ef4444;
53
+ --status-warning: #f59e0b;
54
+
55
+ /* Shadows */
56
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
57
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
58
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
59
+ --shadow-glow: 0 0 20px rgba(99, 102, 241, 0.15);
60
+
61
+ /* Legacy variable mapping for compatibility */
62
+ --bg-primary: var(--bg-canvas);
63
+ --bg-secondary: var(--bg-surface);
64
+ --border-color: var(--border-light);
65
+ --accent: var(--brand-primary);
66
+ }
67
+
68
+ .dark {
69
+ /* --- DARK THEME (Deep, Rich, Premium) --- */
70
+
71
+ /* Brand Colors - Slightly desaturated for dark mode legibility */
72
+ --brand-primary: #818cf8;
73
+ --brand-primary-rgb: 129, 140, 248;
74
+ /* Indigo 400 */
75
+ --brand-hover: #6366f1;
76
+ /* Indigo 500 */
77
+ --brand-light: rgba(99, 102, 241, 0.15);
78
+ --brand-text: #ffffff;
79
+
80
+ /* Backgrounds - Zinc scale for a rich "Obsidian" feel */
81
+ --bg-canvas: #09090b;
82
+ --bg-canvas-rgb: 9, 9, 11;
83
+ /* Zinc 950 */
84
+ --bg-surface: #18181b;
85
+ --bg-surface-rgb: 24, 24, 27;
86
+ /* Zinc 900 */
87
+ --bg-surface-hover: #27272a;
88
+ /* Zinc 800 */
89
+ --bg-modal: rgba(24, 24, 27, 0.96);
90
+ /* More solid for better text contrast */
91
+
92
+ /* Text */
93
+ --text-primary: #f4f4f5;
94
+ /* Zinc 100 */
95
+ --text-secondary: #a1a1aa;
96
+ /* Zinc 400 */
97
+ --text-tertiary: #52525b;
98
+ /* Zinc 600 */
99
+
100
+ /* Borders */
101
+ --border-light: #27272a;
102
+ /* Zinc 800 */
103
+ --border-medium: #3f3f46;
104
+ /* Zinc 700 */
105
+
106
+ /* Shadows */
107
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.5);
108
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.5), 0 2px 4px -2px rgb(0 0 0 / 0.5);
109
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.5);
110
+ --shadow-glow: 0 0 25px rgba(129, 140, 248, 0.15);
111
+
112
+ /* Legacy mapping */
113
+ --bg-primary: var(--bg-canvas);
114
+ --bg-secondary: var(--bg-surface);
115
+ --border-color: var(--border-light);
116
+ --accent: var(--brand-primary);
117
+ }
118
+
119
+ /* =========================================
120
+ GLOBAL RESET & BASE
121
+ ========================================= */
122
+ * {
123
+ margin: 0;
124
+ padding: 0;
125
+ box-sizing: border-box;
126
+ }
127
+
128
+ html,
129
+ body {
130
+ height: 100%;
131
+ overflow: hidden;
132
+ /* App-like feel */
133
+ overscroll-behavior: none;
134
+ /* Prevent bounce/rubber-band effect */
135
+ }
136
+
137
+ body {
138
+ font-family: 'Outfit', 'Inter', system-ui, sans-serif;
139
+ /* Outfit for headings, Inter for body */
140
+ background-color: var(--bg-canvas);
141
+ color: var(--text-primary);
142
+ /* Removed transition on color/background to prevent flickering on theme switch */
143
+ -webkit-font-smoothing: antialiased;
144
+ -moz-osx-font-smoothing: grayscale;
145
+ }
146
+
147
+ /* Selection Highlight */
148
+ ::selection {
149
+ background-color: var(--brand-primary);
150
+ color: white;
151
+ }
152
+
153
+ /* Scrollbar Styling - Minimalist */
154
+ ::-webkit-scrollbar {
155
+ width: 6px;
156
+ height: 6px;
157
+ }
158
+
159
+ ::-webkit-scrollbar-track {
160
+ background: transparent;
161
+ }
162
+
163
+ ::-webkit-scrollbar-thumb {
164
+ background: var(--border-light);
165
+ border-radius: 10px;
166
+ transition: background 0.2s;
167
+ }
168
+
169
+ ::-webkit-scrollbar-thumb:hover {
170
+ background: var(--text-tertiary);
171
+ }
172
+
173
+ /* =========================================
174
+ UTILITIES & COMPONENTS
175
+ ========================================= */
176
+
177
+ @layer utilities {
178
+
179
+ /* Modern Glassmorphism */
180
+ .glass {
181
+ background: rgba(255, 255, 255, 0.65);
182
+ backdrop-filter: blur(16px) saturate(180%);
183
+ -webkit-backdrop-filter: blur(16px) saturate(180%);
184
+ border: 1px solid rgba(255, 255, 255, 0.3);
185
+ }
186
+
187
+ .dark .glass {
188
+ background: rgba(10, 10, 12, 0.65);
189
+ border: 1px solid rgba(255, 255, 255, 0.08);
190
+ }
191
+
192
+ .glass-panel {
193
+ background: var(--bg-modal);
194
+ backdrop-filter: blur(20px);
195
+ border: 1px solid var(--border-light);
196
+ }
197
+
198
+ /* Text Gradients */
199
+ .text-gradient {
200
+ background: linear-gradient(135deg, var(--brand-primary), #a855f7);
201
+ -webkit-background-clip: text;
202
+ background-clip: text;
203
+ -webkit-text-fill-color: transparent;
204
+ }
205
+ }
206
+
207
+ /* Markdown / Message Content Styling */
208
+ .message-content {
209
+ line-height: 1.7;
210
+ font-size: 0.95rem;
211
+ }
212
+
213
+ .message-content p {
214
+ margin-bottom: 0.85rem;
215
+ }
216
+
217
+ .message-content p:last-child {
218
+ margin-bottom: 0;
219
+ }
220
+
221
+ .message-content strong {
222
+ font-weight: 600;
223
+ color: var(--text-primary);
224
+ }
225
+
226
+ .message-content a {
227
+ color: var(--brand-primary);
228
+ text-decoration: none;
229
+ border-bottom: 1px dashed var(--brand-primary);
230
+ transition: all 0.2s;
231
+ }
232
+
233
+ .message-content a:hover {
234
+ border-style: solid;
235
+ }
236
+
237
+ /* Code Blocks */
238
+ .message-content pre {
239
+ background: var(--bg-surface);
240
+ border: 1px solid var(--border-light);
241
+ border-radius: 0.75rem;
242
+ padding: 1.25rem;
243
+ overflow-x: auto;
244
+ margin: 1.25rem 0;
245
+ box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.02);
246
+ }
247
+
248
+ .message-content code {
249
+ font-family: 'JetBrains Mono', monospace;
250
+ font-size: 0.85em;
251
+ padding: 0.2em 0.4em;
252
+ border-radius: 0.25rem;
253
+ background: var(--bg-surface-hover);
254
+ color: var(--brand-primary);
255
+ }
256
+
257
+ .message-content pre code {
258
+ background: transparent;
259
+ padding: 0;
260
+ color: inherit;
261
+ font-size: 0.9em;
262
+ }
263
+
264
+ /* Lists */
265
+ .message-content ul,
266
+ .message-content ol {
267
+ padding-left: 1.5rem;
268
+ margin: 0.75rem 0;
269
+ }
270
+
271
+ .message-content li {
272
+ margin: 0.4rem 0;
273
+ padding-left: 0.5rem;
274
+ }
275
+
276
+ /* KaTeX Override (Math) - Minimalist Integrated Look */
277
+ .katex-display {
278
+ margin: 1.5rem 0;
279
+ padding: 0.5rem 0;
280
+ background: transparent;
281
+ border: none;
282
+ border-radius: 0;
283
+ overflow-x: auto;
284
+ box-shadow: none;
285
+ position: relative;
286
+ transition: color 0.3s ease;
287
+ }
288
+
289
+ .katex-display:hover {
290
+ border-color: transparent;
291
+ box-shadow: none;
292
+ }
293
+
294
+ .katex {
295
+ font-size: 1.1em;
296
+ color: var(--text-primary);
297
+ }
298
+
299
+ .katex-error {
300
+ color: var(--status-error);
301
+ background: rgba(239, 68, 68, 0.1);
302
+ padding: 2px 6px;
303
+ border-radius: 4px;
304
+ }
305
+
306
+ /* =========================================
307
+ ANIMATIONS
308
+ ========================================= */
309
+ @keyframes fadeIn {
310
+ from {
311
+ opacity: 0;
312
+ transform: translateY(5px);
313
+ }
314
+
315
+ to {
316
+ opacity: 1;
317
+ transform: translateY(0);
318
+ }
319
+ }
320
+
321
+ @keyframes popIn {
322
+ 0% {
323
+ transform: scale(0.9);
324
+ opacity: 0;
325
+ }
326
+
327
+ 100% {
328
+ transform: scale(1);
329
+ opacity: 1;
330
+ }
331
+ }
332
+
333
+ @keyframes messageSlideUp {
334
+ from {
335
+ opacity: 0;
336
+ transform: translateY(20px);
337
+ }
338
+
339
+ to {
340
+ opacity: 1;
341
+ transform: translateY(0);
342
+ }
343
+ }
344
+
345
+ .animate-msg-slide-up {
346
+ animation: messageSlideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
347
+ }
348
+
349
+ /* Agent Tracking UI Refinements */
350
+ .agent-status-badge {
351
+ display: inline-flex;
352
+ align-items: center;
353
+ background: var(--brand-light);
354
+ color: var(--brand-primary);
355
+ padding: 0.35rem 0.85rem;
356
+ border-radius: 20px;
357
+ font-size: 0.75rem;
358
+ font-weight: 700;
359
+ margin-bottom: 0.75rem;
360
+ border: 1px solid rgba(var(--brand-primary-rgb), 0.2);
361
+ text-transform: uppercase;
362
+ letter-spacing: 0.08em;
363
+ box-shadow: 0 4px 10px rgba(99, 102, 241, 0.1);
364
+ position: relative;
365
+ overflow: hidden;
366
+ }
367
+
368
+ .agent-status-badge::after {
369
+ content: '';
370
+ position: absolute;
371
+ top: 0;
372
+ left: -100%;
373
+ width: 50%;
374
+ height: 100%;
375
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
376
+ animation: badgeShine 2s infinite;
377
+ }
378
+
379
+ @keyframes badgeShine {
380
+ 0% {
381
+ left: -100%;
382
+ }
383
+
384
+ 100% {
385
+ left: 200%;
386
+ }
387
+ }
388
+
389
+ .agent-steps {
390
+ margin-bottom: 1.25rem;
391
+ font-size: 0.85rem;
392
+ border: 1px solid var(--border-light);
393
+ border-radius: 0.75rem;
394
+ overflow: hidden;
395
+ background: var(--bg-surface);
396
+ box-shadow: var(--shadow-sm);
397
+ }
398
+
399
+ .agent-steps summary {
400
+ padding: 0.75rem 1rem;
401
+ cursor: pointer;
402
+ font-weight: 500;
403
+ color: var(--text-secondary);
404
+ transition: color 0.2s;
405
+ background: var(--bg-secondary);
406
+ }
407
+
408
+ .agent-steps summary:hover {
409
+ color: var(--brand-primary);
410
+ }
411
+
412
+ .steps-list {
413
+ padding: 0.75rem;
414
+ gap: 0.75rem;
415
+ }
416
+
417
+ .step-item {
418
+ background: var(--bg-canvas);
419
+ border: 1px solid var(--border-light);
420
+ border-radius: 0.5rem;
421
+ padding: 0.75rem;
422
+ }
423
+
424
+ .step-tool {
425
+ color: var(--brand-primary);
426
+ }
frontend/src/main.jsx ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from 'react'
2
+ import { createRoot } from 'react-dom/client'
3
+ import './index.css'
4
+ import App from './App.jsx'
5
+
6
+ import ErrorBoundary from './components/ErrorBoundary.jsx'
7
+
8
+ createRoot(document.getElementById('root')).render(
9
+ <StrictMode>
10
+ <ErrorBoundary fallback={<div className="p-4 text-red-500"><h1>Application Crashed</h1><p>Check console for details.</p></div>}>
11
+ <App />
12
+ </ErrorBoundary>
13
+ </StrictMode>,
14
+ )
frontend/src/utils/chatUtils.js ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Preprocess LaTeX content to ensure proper rendering
3
+ * Convert \[...\] to $$...$$ and \(...\) to $...$
4
+ */
5
+ export const preprocessLaTeX = (content) => {
6
+ if (!content) return ''
7
+ let processed = content
8
+
9
+ // Convert \[...\] to $$...$$ (display math)
10
+ processed = processed.replace(/\\\[([\s\S]*?)\\\]/g, (match, math) => `$$${math}$$`)
11
+
12
+ // Convert \(...\) to $...$ (inline math)
13
+ processed = processed.replace(/\\\(([\s\S]*?)\\\)/g, (match, math) => `$${math}$`)
14
+
15
+ return processed
16
+ }
17
+
18
+ /**
19
+ * Parse message content - handles both legacy JSON blocks and new plain markdown
20
+ */
21
+ export const parseMessageContent = (content) => {
22
+ if (!content) return ''
23
+ if (content.trim().startsWith('{') && content.includes('"blocks"')) {
24
+ try {
25
+ const parsed = JSON.parse(content)
26
+ if (parsed.blocks && Array.isArray(parsed.blocks)) {
27
+ return parsed.blocks.map(block => {
28
+ if (block.type === 'text') return block.content || ''
29
+ else if (block.type === 'math') {
30
+ const latex = block.latex || block.content || ''
31
+ const display = block.display === 'inline' ? '$' : '$$'
32
+ return `${display}${latex}${display}`
33
+ } else if (block.type === 'list') {
34
+ const items = block.items || []
35
+ return items.map((item, i) => `${block.ordered ? `${i + 1}.` : '-'} ${item}`).join('\n')
36
+ }
37
+ return ''
38
+ }).join('\n\n')
39
+ }
40
+ } catch { /* Not valid JSON */ }
41
+ }
42
+ return content.replace(/\\n/g, '\n')
43
+ }
frontend/tailwind.config.js ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ content: [
4
+ "./index.html",
5
+ "./src/**/*.{js,ts,jsx,tsx}",
6
+ ],
7
+ darkMode: 'class',
8
+ theme: {
9
+ extend: {
10
+ colors: {
11
+ // Semantic Colors (mapping to CSS variables)
12
+ 'brand': {
13
+ primary: 'var(--brand-primary)',
14
+ hover: 'var(--brand-hover)',
15
+ light: 'var(--brand-light)',
16
+ text: 'var(--brand-text)',
17
+ },
18
+ 'bg': {
19
+ canvas: 'var(--bg-canvas)',
20
+ surface: 'var(--bg-surface)',
21
+ 'surface-hover': 'var(--bg-surface-hover)',
22
+ modal: 'var(--bg-modal)',
23
+ },
24
+ 'text': {
25
+ primary: 'var(--text-primary)',
26
+ secondary: 'var(--text-secondary)',
27
+ tertiary: 'var(--text-tertiary)',
28
+ },
29
+ 'border': {
30
+ light: 'var(--border-light)',
31
+ medium: 'var(--border-medium)',
32
+ },
33
+ 'status': {
34
+ success: 'var(--status-success)',
35
+ error: 'var(--status-error)',
36
+ warning: 'var(--status-warning)',
37
+ },
38
+
39
+ // Legacy support (optional, if you want to keep old names working temporarily)
40
+ primary: {
41
+ 50: '#f0f9ff',
42
+ 100: '#e0f2fe',
43
+ 200: '#bae6fd',
44
+ 300: '#7dd3fc',
45
+ 400: '#38bdf8',
46
+ 500: '#0ea5e9',
47
+ 600: '#0284c7',
48
+ 700: '#0369a1',
49
+ 800: '#075985',
50
+ 900: '#0c4a6e',
51
+ },
52
+ },
53
+ fontFamily: {
54
+ sans: ['Inter', 'system-ui', 'sans-serif'],
55
+ mono: ['JetBrains Mono', 'monospace'],
56
+ display: ['Outfit', 'sans-serif'],
57
+ },
58
+ animation: {
59
+ 'fade-in': 'fadeIn 0.3s ease-out',
60
+ 'slide-up': 'slideUp 0.3s ease-out',
61
+ 'pulse-soft': 'pulseSoft 2s infinite',
62
+ },
63
+ keyframes: {
64
+ fadeIn: {
65
+ '0%': { opacity: '0' },
66
+ '100%': { opacity: '1' },
67
+ },
68
+ slideUp: {
69
+ '0%': { opacity: '0', transform: 'translateY(10px)' },
70
+ '100%': { opacity: '1', transform: 'translateY(0)' },
71
+ },
72
+ pulseSoft: {
73
+ '0%, 100%': { opacity: '1' },
74
+ '50%': { opacity: '0.5' },
75
+ },
76
+ },
77
+ },
78
+ },
79
+ plugins: [
80
+ require('@tailwindcss/typography'),
81
+ ],
82
+ }
frontend/vite.config.js ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import tailwindcss from '@tailwindcss/vite'
4
+
5
+ // https://vite.dev/config/
6
+ export default defineConfig({
7
+ plugins: [react(), tailwindcss()],
8
+ server: {
9
+ proxy: {
10
+ '/api': {
11
+ target: 'http://localhost:7860',
12
+ changeOrigin: true,
13
+ },
14
+ },
15
+ },
16
+ })