Spaces:
Running
Running
Commit ·
728283f
1
Parent(s): 91c2e16
feat(ai): design improvements for chat popup, speeddial menu, resizable answer card, and centered header disclaimer
Browse files
webapp/tipitaka-web/src/components/ai/AIFloatingMenu.tsx
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { motion, AnimatePresence, type Variants } from 'framer-motion';
|
| 3 |
+
import { Sparkles, X, MessageSquareText, FileText, Scale, Lightbulb, MessageSquare } from 'lucide-react';
|
| 4 |
+
import { useAIStore } from '../../stores/aiStore';
|
| 5 |
+
import { useReaderStore, useThemeStore } from '../../stores/appStore';
|
| 6 |
+
|
| 7 |
+
const MENU_STYLES: Record<string, { btnBg: string; text: string; border: string; labelBg: string }> = {
|
| 8 |
+
dark: { btnBg: 'bg-[#1a1a2e]/90', text: 'text-white', border: 'border-[#3a3a5e]/60', labelBg: 'bg-[#0f0f1e]/95 text-white/90 border-[#3a3a5e]' },
|
| 9 |
+
light: { btnBg: 'bg-[#fdfaf5]/90', text: 'text-[#1a1a1a]', border: 'border-[#e0d0b0]/60', labelBg: 'bg-[#f5edd8]/95 text-[#1a1a1a] border-[#e0d0b0]' },
|
| 10 |
+
classic: { btnBg: 'bg-[#faf7f2]/90', text: 'text-[#1a1a1a]', border: 'border-[#d4c4a0]/60', labelBg: 'bg-[#fdfaf5]/95 text-[#1a1a1a] border-[#d4c4a0]' },
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
const AIFloatingMenu: React.FC = () => {
|
| 14 |
+
const [isOpenMenu, setIsOpenMenu] = useState(false);
|
| 15 |
+
const { theme } = useThemeStore();
|
| 16 |
+
const { currentVolume, currentPage, currentContent } = useReaderStore();
|
| 17 |
+
const { askAI, setViewMode, isOpen, toggleOpen, isStreaming } = useAIStore();
|
| 18 |
+
|
| 19 |
+
const s = MENU_STYLES[theme] ?? MENU_STYLES.dark;
|
| 20 |
+
|
| 21 |
+
const actions = [
|
| 22 |
+
{
|
| 23 |
+
icon: <MessageSquareText size={18} />,
|
| 24 |
+
label: 'อธิบาย',
|
| 25 |
+
prompt: 'ช่วยอธิบายเนื้อหานี้ให้เข้าใจง่ายขึ้น',
|
| 26 |
+
isPrompt: true,
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
icon: <FileText size={18} />,
|
| 30 |
+
label: 'สรุป',
|
| 31 |
+
prompt: 'ช่วยสรุปใจความสำคัญของเนื้อหานี้เป็นข้อๆ',
|
| 32 |
+
isPrompt: true,
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
icon: <Scale size={18} />,
|
| 36 |
+
label: 'วิเคราะห์ธรรม',
|
| 37 |
+
prompt: 'ช่วยวิเคราะห์หลักธรรมที่ปรากฏในเนื้อหานี้',
|
| 38 |
+
isPrompt: true,
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
icon: <Lightbulb size={18} />,
|
| 42 |
+
label: 'ประยุกต์ใช้',
|
| 43 |
+
prompt: 'หลักธรรมนี้ประยุกต์ใช้ในชีวิตประจำวันได้อย่างไร',
|
| 44 |
+
isPrompt: true,
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
icon: <MessageSquare size={18} />,
|
| 48 |
+
label: 'แชทสอบถาม',
|
| 49 |
+
prompt: null,
|
| 50 |
+
isPrompt: false,
|
| 51 |
+
},
|
| 52 |
+
];
|
| 53 |
+
|
| 54 |
+
const handleAction = async (prompt: string | null, isPrompt: boolean) => {
|
| 55 |
+
setIsOpenMenu(false);
|
| 56 |
+
|
| 57 |
+
if (isPrompt && prompt) {
|
| 58 |
+
setViewMode('answer');
|
| 59 |
+
const context = `เล่มที่ ${currentVolume} หน้าที่ ${currentPage}\nเนื้อหา:\n${currentContent}`;
|
| 60 |
+
await askAI(prompt, context);
|
| 61 |
+
} else {
|
| 62 |
+
setViewMode('chat');
|
| 63 |
+
// If not open, open it
|
| 64 |
+
if (!isOpen) {
|
| 65 |
+
toggleOpen();
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
const containerVariants: Variants = {
|
| 71 |
+
open: {
|
| 72 |
+
transition: {
|
| 73 |
+
staggerChildren: 0.05,
|
| 74 |
+
},
|
| 75 |
+
},
|
| 76 |
+
closed: {
|
| 77 |
+
transition: {
|
| 78 |
+
staggerChildren: 0.05,
|
| 79 |
+
staggerDirection: -1,
|
| 80 |
+
},
|
| 81 |
+
},
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
const itemVariants: Variants = {
|
| 85 |
+
open: {
|
| 86 |
+
opacity: 1,
|
| 87 |
+
y: 0,
|
| 88 |
+
scale: 1,
|
| 89 |
+
transition: { type: 'spring', stiffness: 300, damping: 25 },
|
| 90 |
+
},
|
| 91 |
+
closed: {
|
| 92 |
+
opacity: 0,
|
| 93 |
+
y: 20,
|
| 94 |
+
scale: 0.8,
|
| 95 |
+
transition: { duration: 0.15 },
|
| 96 |
+
},
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
// If the main AI Chat/Answer window is open, hide the Speed Dial menu
|
| 100 |
+
if (isOpen) return null;
|
| 101 |
+
|
| 102 |
+
return (
|
| 103 |
+
<>
|
| 104 |
+
{/* Background Overlay (Click outside to close) */}
|
| 105 |
+
<AnimatePresence>
|
| 106 |
+
{isOpenMenu && (
|
| 107 |
+
<motion.div
|
| 108 |
+
initial={{ opacity: 0 }}
|
| 109 |
+
animate={{ opacity: 1 }}
|
| 110 |
+
exit={{ opacity: 0 }}
|
| 111 |
+
onClick={() => setIsOpenMenu(false)}
|
| 112 |
+
className="fixed inset-0 z-30 bg-black/10 backdrop-blur-[2px]"
|
| 113 |
+
/>
|
| 114 |
+
)}
|
| 115 |
+
</AnimatePresence>
|
| 116 |
+
|
| 117 |
+
<div className="fixed right-14 bottom-14 lg:bottom-6 z-40 flex flex-col items-end">
|
| 118 |
+
{/* Speed Dial Menu Items */}
|
| 119 |
+
<AnimatePresence>
|
| 120 |
+
{isOpenMenu && (
|
| 121 |
+
<motion.div
|
| 122 |
+
variants={containerVariants}
|
| 123 |
+
initial="closed"
|
| 124 |
+
animate="open"
|
| 125 |
+
exit="closed"
|
| 126 |
+
className="flex flex-col items-end gap-3 mb-4 pointer-events-auto"
|
| 127 |
+
>
|
| 128 |
+
{actions.map((act, idx) => (
|
| 129 |
+
<motion.div
|
| 130 |
+
key={idx}
|
| 131 |
+
variants={itemVariants}
|
| 132 |
+
className="flex items-center group cursor-pointer"
|
| 133 |
+
onClick={() => handleAction(act.prompt, act.isPrompt)}
|
| 134 |
+
>
|
| 135 |
+
{/* Label Pill */}
|
| 136 |
+
<span className={`mr-3 px-3 py-1.5 rounded-xl text-[11px] font-bold border shadow-md whitespace-nowrap transition-transform duration-200 group-hover:scale-105 ${s.labelBg}`}>
|
| 137 |
+
{act.label}
|
| 138 |
+
</span>
|
| 139 |
+
|
| 140 |
+
{/* Action Button */}
|
| 141 |
+
<button
|
| 142 |
+
disabled={isStreaming}
|
| 143 |
+
className={`w-11 h-11 rounded-full flex items-center justify-center border shadow-lg backdrop-blur-md transition-all duration-200 group-hover:scale-110 active:scale-95 disabled:opacity-50 ${s.btnBg} ${s.text} ${s.border} hover:bg-[#c8860a] hover:text-white hover:border-[#c8860a]`}
|
| 144 |
+
title={act.label}
|
| 145 |
+
>
|
| 146 |
+
{act.icon}
|
| 147 |
+
</button>
|
| 148 |
+
</motion.div>
|
| 149 |
+
))}
|
| 150 |
+
</motion.div>
|
| 151 |
+
)}
|
| 152 |
+
</AnimatePresence>
|
| 153 |
+
|
| 154 |
+
{/* Main Floating Action Button (FAB) */}
|
| 155 |
+
<motion.button
|
| 156 |
+
onClick={() => setIsOpenMenu(v => !v)}
|
| 157 |
+
className="w-14 h-14 rounded-full bg-[#c8860a] text-white flex items-center justify-center shadow-xl hover:bg-[#9a6307] transition-all duration-200 active:scale-95"
|
| 158 |
+
style={{ transformOrigin: 'center' }}
|
| 159 |
+
animate={{ rotate: isOpenMenu ? 135 : 0 }}
|
| 160 |
+
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
| 161 |
+
title="ผู้ช่วย AI"
|
| 162 |
+
>
|
| 163 |
+
{isOpenMenu ? <X size={24} /> : <Sparkles size={24} />}
|
| 164 |
+
</motion.button>
|
| 165 |
+
</div>
|
| 166 |
+
</>
|
| 167 |
+
);
|
| 168 |
+
};
|
| 169 |
+
|
| 170 |
+
export default AIFloatingMenu;
|
webapp/tipitaka-web/src/components/ai/AIPopup.tsx
CHANGED
|
@@ -12,20 +12,24 @@ const PANEL_STYLES: Record<string, { bg: string; text: string; border: string; m
|
|
| 12 |
classic: { bg: 'bg-[#fdfaf5]', text: 'text-[#1a1a1a]', border: 'border-[#d4c4a0]', msgBg: 'bg-[#faf7f2]', focusBg: 'bg-[#f8f4ed]' },
|
| 13 |
};
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
const AIPopup: React.FC = () => {
|
| 16 |
const {
|
| 17 |
isOpen, toggleOpen, messages, isStreaming,
|
| 18 |
askAI, clearHistory, mode, setMode,
|
| 19 |
useRag, setUseRag, checkRagStatus, ragReady, ragChecking, ragLoading,
|
| 20 |
-
dragPos, setDragPos,
|
| 21 |
} = useAIStore();
|
| 22 |
|
| 23 |
const { currentVolume, currentPage, currentContent } = useReaderStore();
|
| 24 |
const { theme } = useThemeStore();
|
|
|
|
| 25 |
const [input, setInput] = useState('');
|
| 26 |
-
const [showAiDisclaimer, setShowAiDisclaimer] = useState(false);
|
| 27 |
-
const [hasShownDisclaimer, setHasShownDisclaimer] = useState(false);
|
| 28 |
-
const [showQuickPrompts, setShowQuickPrompts] = useState(true);
|
| 29 |
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
| 30 |
|
| 31 |
const scrollRef = useRef<HTMLDivElement>(null);
|
|
@@ -69,23 +73,25 @@ const AIPopup: React.FC = () => {
|
|
| 69 |
}
|
| 70 |
}, []);
|
| 71 |
|
| 72 |
-
|
| 73 |
-
useEffect(() => {
|
| 74 |
-
if (isOpen && !hasShownDisclaimer) {
|
| 75 |
-
setShowAiDisclaimer(true);
|
| 76 |
-
const timer = setTimeout(() => {
|
| 77 |
-
setShowAiDisclaimer(false);
|
| 78 |
-
setHasShownDisclaimer(true);
|
| 79 |
-
}, 10000);
|
| 80 |
-
return () => clearTimeout(timer);
|
| 81 |
-
}
|
| 82 |
-
}, [isOpen, hasShownDisclaimer]);
|
| 83 |
|
| 84 |
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
| 85 |
// Only desktop — ignore if touch device or small screen
|
| 86 |
if (window.innerWidth < 768) return;
|
| 87 |
if (expandedIndex !== null) return; // Disable drag in focus mode
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
setIsDragging(true);
|
| 90 |
const rect = panelRef.current?.getBoundingClientRect();
|
| 91 |
if (rect) {
|
|
@@ -297,7 +303,7 @@ const AIPopup: React.FC = () => {
|
|
| 297 |
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
| 298 |
className={`
|
| 299 |
fixed z-50 flex flex-col overflow-hidden rounded-2xl border shadow-2xl
|
| 300 |
-
${s.bg} ${s.text} ${s.border}
|
| 301 |
bottom-24
|
| 302 |
${pos && window.innerWidth >= 768 ? '' : 'left-1/2 -translate-x-1/2 md:left-auto md:right-14 md:translate-x-0'}
|
| 303 |
md:w-auto
|
|
@@ -306,7 +312,7 @@ const AIPopup: React.FC = () => {
|
|
| 306 |
${isDragging ? 'cursor-grabbing select-none' : ''}
|
| 307 |
${resizeDir ? 'select-none' : ''}
|
| 308 |
${expandedIndex !== null ? 'pointer-events-none opacity-0' : ''}
|
| 309 |
-
transition-
|
| 310 |
`}
|
| 311 |
style={{
|
| 312 |
width: window.innerWidth >= 768 ? width : undefined,
|
|
@@ -336,198 +342,282 @@ const AIPopup: React.FC = () => {
|
|
| 336 |
</div>
|
| 337 |
</>
|
| 338 |
)}
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
</div>
|
| 359 |
</div>
|
| 360 |
-
|
| 361 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
<button
|
| 363 |
-
onClick={() =>
|
| 364 |
-
|
| 365 |
-
className={`flex items-center gap-1 px-
|
| 366 |
-
|
| 367 |
-
}
|
| 368 |
-
|
|
|
|
| 369 |
<button
|
| 370 |
-
onClick={
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
</div>
|
| 377 |
-
<button onClick={clearHistory} onMouseDown={e => e.stopPropagation()} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors" title="ล้างการสนทนา" aria-label="ล้างการสนทนา"><Trash2 size={14} /></button>
|
| 378 |
-
<button onClick={toggleOpen} onMouseDown={e => e.stopPropagation()} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors" aria-label="ปิด"><X size={16} /></button>
|
| 379 |
</div>
|
| 380 |
-
</
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
<
|
| 385 |
-
|
| 386 |
-
onMouseDown={
|
| 387 |
-
|
| 388 |
>
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
<
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
onMouseDown={e => e.stopPropagation()}
|
| 404 |
-
className="text-[10px] font-bold text-white/50 hover:text-white transition-colors"
|
| 405 |
-
>{showQuickPrompts ? '▲ ซ่อนปุ่มลัด' : '▼ ปุ่มลัด'}</button>
|
| 406 |
-
</div>
|
| 407 |
-
</div>
|
| 408 |
-
|
| 409 |
-
{/* ═══════════ Quick Prompt / Disclaimer ═══════════ */}
|
| 410 |
-
<div className={`relative overflow-hidden transition-all duration-300 ${
|
| 411 |
-
(showQuickPrompts || showAiDisclaimer) ? 'max-h-40 border-b py-2' : 'max-h-0 border-transparent'
|
| 412 |
-
} ${s.border}`}>
|
| 413 |
-
<AnimatePresence mode="wait">
|
| 414 |
-
{showAiDisclaimer ? (
|
| 415 |
-
<motion.div
|
| 416 |
-
key="disclaimer"
|
| 417 |
-
initial={{ opacity: 0, y: 10 }}
|
| 418 |
-
animate={{ opacity: 1, y: 0 }}
|
| 419 |
-
exit={{ opacity: 0, y: -10 }}
|
| 420 |
-
className="px-4 py-1"
|
| 421 |
-
>
|
| 422 |
-
<div className="bg-[#c8860a]/10 border border-[#c8860a]/30 rounded-xl p-2.5 relative">
|
| 423 |
-
<button
|
| 424 |
-
onClick={() => { setShowAiDisclaimer(false); setHasShownDisclaimer(true); }}
|
| 425 |
-
className="absolute top-1 right-1 p-1 opacity-50 hover:opacity-100 transition-opacity"
|
| 426 |
-
>
|
| 427 |
-
<X size={12} />
|
| 428 |
-
</button>
|
| 429 |
-
<p className="text-[10.5px] leading-relaxed text-[#c8860a] font-medium pr-4">
|
| 430 |
-
<span className="font-bold">💡 ข้อชี้แจง:</span> บทวิเคราะห์นี้ประมวลผลโดยเทคโนโลยีปัญญาประดิษฐ์ (AI) ผู้อ่านพึงใช้วิจารณญาณและสอบทานข้อมูลอย่างรอบด้านตามหลักกาลามสูตร ก่อนนำไปอ้างอิงหรือปฏิบัติ
|
| 431 |
-
</p>
|
| 432 |
</div>
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
initial={{ opacity: 0 }}
|
| 438 |
-
animate={{ opacity: 1 }}
|
| 439 |
-
exit={{ opacity: 0 }}
|
| 440 |
-
className="grid grid-cols-2 gap-1.5 px-3"
|
| 441 |
-
>
|
| 442 |
-
{[
|
| 443 |
-
{ emoji: '💬', text: 'อธิบาย', prompt: 'ช่วยอธิบายเนื้อหานี้ให้เข้าใจง่ายขึ้น' },
|
| 444 |
-
{ emoji: '📝', text: 'สรุป', prompt: 'ช่วยสรุปใจความสำคัญของเนื้อหานี้เป็นข้อๆ' },
|
| 445 |
-
{ emoji: '⚖️', text: 'วิเคราะห์ธรรม', prompt: 'ช่วยวิเคราะห์หลักธรรมที่ปรากฏในเนื้อหานี้' },
|
| 446 |
-
{ emoji: '💡', text: 'ประยุกต์ใช้', prompt: 'หลักธรรมนี้ประยุกต์ใช้ในชีวิตประจำวันได้อย่างไร' },
|
| 447 |
-
].map(({ emoji, text, prompt }) => (
|
| 448 |
-
<button key={text} onClick={() => { const c = `เล่มที่ ${currentVolume} หน้าที่ ${currentPage}\nเนื้อหา:\n${currentContent}`; askAI(prompt, c); }}
|
| 449 |
-
disabled={isStreaming}
|
| 450 |
-
className={`flex items-center justify-center gap-1 px-2 py-1.5 text-[11px] font-medium rounded-xl border transition-all ${s.msgBg} ${s.border} hover:border-[#c8860a]/50 hover:text-[#c8860a] disabled:opacity-40`}
|
| 451 |
-
>{emoji} {text}</button>
|
| 452 |
-
))}
|
| 453 |
-
</motion.div>
|
| 454 |
-
)}
|
| 455 |
-
</AnimatePresence>
|
| 456 |
-
</div>
|
| 457 |
-
|
| 458 |
-
{/* ═══════════ Messages ═══════════ */}
|
| 459 |
-
<div className="relative flex-1 min-h-0 flex flex-col">
|
| 460 |
-
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3 md:p-4 space-y-4">
|
| 461 |
-
{messages.length === 0 && (
|
| 462 |
-
<div className="h-full flex flex-col items-center justify-center text-center opacity-50 px-8 gap-3">
|
| 463 |
-
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${s.msgBg}`}>
|
| 464 |
-
<Sparkles size={24} className="text-[#c8860a]" />
|
| 465 |
</div>
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
<
|
|
|
|
| 469 |
</div>
|
| 470 |
</div>
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
{
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
}`}>
|
| 480 |
-
{
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
<
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
</div>
|
| 495 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
</div>
|
| 497 |
-
))}
|
| 498 |
-
</div>
|
| 499 |
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
|
|
|
|
|
|
| 513 |
</div>
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
<
|
| 528 |
-
</
|
| 529 |
-
</
|
| 530 |
-
|
| 531 |
</motion.div>
|
| 532 |
</>
|
| 533 |
)}
|
|
|
|
| 12 |
classic: { bg: 'bg-[#fdfaf5]', text: 'text-[#1a1a1a]', border: 'border-[#d4c4a0]', msgBg: 'bg-[#faf7f2]', focusBg: 'bg-[#f8f4ed]' },
|
| 13 |
};
|
| 14 |
|
| 15 |
+
const ANSWER_CARD_STYLES: Record<string, { bg: string; border: string; text: string; btnBg: string; titleBg: string }> = {
|
| 16 |
+
dark: { bg: 'bg-[#0f0f1e]/80 backdrop-blur-md', border: 'border-[#3a3a5e]/80', text: 'text-[#e0e0e0]', btnBg: 'bg-white/10 hover:bg-[#c8860a] text-white border-white/10 hover:border-[#c8860a]', titleBg: 'bg-black/20' },
|
| 17 |
+
light: { bg: 'bg-[#f5edd8]/85 backdrop-blur-md', border: 'border-[#e0d0b0]/80', text: 'text-[#1a1a1a]', btnBg: 'bg-black/5 hover:bg-[#c8860a] text-neutral-800 hover:text-white border-black/10 hover:border-[#c8860a]', titleBg: 'bg-black/5' },
|
| 18 |
+
classic: { bg: 'bg-[#fdfaf5]/85 backdrop-blur-md', border: 'border-[#d4c4a0]/80', text: 'text-[#1a1a1a]', btnBg: 'bg-black/5 hover:bg-[#c8860a] text-neutral-800 hover:text-white border-black/10 hover:border-[#c8860a]', titleBg: 'bg-black/5' },
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
const AIPopup: React.FC = () => {
|
| 22 |
const {
|
| 23 |
isOpen, toggleOpen, messages, isStreaming,
|
| 24 |
askAI, clearHistory, mode, setMode,
|
| 25 |
useRag, setUseRag, checkRagStatus, ragReady, ragChecking, ragLoading,
|
| 26 |
+
dragPos, setDragPos, viewMode, setViewMode,
|
| 27 |
} = useAIStore();
|
| 28 |
|
| 29 |
const { currentVolume, currentPage, currentContent } = useReaderStore();
|
| 30 |
const { theme } = useThemeStore();
|
| 31 |
+
const ac = ANSWER_CARD_STYLES[theme] ?? ANSWER_CARD_STYLES.dark;
|
| 32 |
const [input, setInput] = useState('');
|
|
|
|
|
|
|
|
|
|
| 33 |
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
| 34 |
|
| 35 |
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
| 73 |
}
|
| 74 |
}, []);
|
| 75 |
|
| 76 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
| 79 |
// Only desktop — ignore if touch device or small screen
|
| 80 |
if (window.innerWidth < 768) return;
|
| 81 |
if (expandedIndex !== null) return; // Disable drag in focus mode
|
| 82 |
|
| 83 |
+
// Check if the click was on interactive elements
|
| 84 |
+
const target = e.target as HTMLElement;
|
| 85 |
+
if (
|
| 86 |
+
target.closest('button') ||
|
| 87 |
+
target.closest('input') ||
|
| 88 |
+
target.closest('select') ||
|
| 89 |
+
target.closest('a') ||
|
| 90 |
+
target.closest('[role="button"]')
|
| 91 |
+
) {
|
| 92 |
+
return;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
setIsDragging(true);
|
| 96 |
const rect = panelRef.current?.getBoundingClientRect();
|
| 97 |
if (rect) {
|
|
|
|
| 303 |
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
| 304 |
className={`
|
| 305 |
fixed z-50 flex flex-col overflow-hidden rounded-2xl border shadow-2xl
|
| 306 |
+
${viewMode === 'answer' ? `${ac.bg} ${ac.text} ${ac.border}` : `${s.bg} ${s.text} ${s.border}`}
|
| 307 |
bottom-24
|
| 308 |
${pos && window.innerWidth >= 768 ? '' : 'left-1/2 -translate-x-1/2 md:left-auto md:right-14 md:translate-x-0'}
|
| 309 |
md:w-auto
|
|
|
|
| 312 |
${isDragging ? 'cursor-grabbing select-none' : ''}
|
| 313 |
${resizeDir ? 'select-none' : ''}
|
| 314 |
${expandedIndex !== null ? 'pointer-events-none opacity-0' : ''}
|
| 315 |
+
transition-all duration-300
|
| 316 |
`}
|
| 317 |
style={{
|
| 318 |
width: window.innerWidth >= 768 ? width : undefined,
|
|
|
|
| 342 |
</div>
|
| 343 |
</>
|
| 344 |
)}
|
| 345 |
+
|
| 346 |
+
{viewMode === 'answer' ? (
|
| 347 |
+
<>
|
| 348 |
+
{/* ═══════════ Compact Answer Card Header ═══════════ */}
|
| 349 |
+
<div
|
| 350 |
+
className={`flex flex-col flex-shrink-0 relative overflow-hidden border-b ${ac.border}`}
|
| 351 |
+
onMouseDown={handleMouseDown}
|
| 352 |
+
style={{ cursor: window.innerWidth >= 768 ? 'grab' : undefined }}
|
| 353 |
+
>
|
| 354 |
+
<div className={`absolute inset-0 ${ac.titleBg}`} />
|
| 355 |
+
<div className="relative flex items-center justify-between px-3 py-2.5 z-10">
|
| 356 |
+
<div className="flex items-center gap-2.5 min-w-0">
|
| 357 |
+
<div className="w-7 h-7 rounded-lg bg-[#c8860a] flex items-center justify-center shadow-inner">
|
| 358 |
+
<Sparkles size={15} className="text-white animate-pulse" />
|
| 359 |
+
</div>
|
| 360 |
+
<div>
|
| 361 |
+
<h3 className="font-bold text-xs tracking-wide uppercase text-[#c8860a]">
|
| 362 |
+
{isStreaming ? 'กำลังประมวลผลด้วย AI...' : 'บทวิเคราะห์ธรรมจาก AI'}
|
| 363 |
+
</h3>
|
| 364 |
+
</div>
|
| 365 |
+
</div>
|
| 366 |
+
|
| 367 |
+
{/* Disclaimer in the middle of title bar (Desktop only) */}
|
| 368 |
+
<div className="hidden md:block absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-[9.5px] font-semibold text-[#c8860a] max-w-[50%] text-center leading-normal select-none pointer-events-none animate-pulse">
|
| 369 |
+
💡 บทวิเคราะห์นี้ประมวลผลโดย AI ผู้อ่านพึงใช้วิจารณญาณและสอบทานข้อมูลอย่างรอบด้านตามหลักกาลามสูตร
|
| 370 |
+
</div>
|
| 371 |
+
|
| 372 |
+
<button
|
| 373 |
+
onClick={toggleOpen}
|
| 374 |
+
onMouseDown={e => e.stopPropagation()}
|
| 375 |
+
className="p-1.5 hover:bg-white/10 rounded-lg transition-colors"
|
| 376 |
+
aria-label="ปิด"
|
| 377 |
+
>
|
| 378 |
+
<X size={16} />
|
| 379 |
+
</button>
|
| 380 |
</div>
|
| 381 |
</div>
|
| 382 |
+
|
| 383 |
+
{/* ═══════════ Answer Card Body (Prose with transparent styles) ═══════════ */}
|
| 384 |
+
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-4">
|
| 385 |
+
{(() => {
|
| 386 |
+
const lastAiIndex = messages.findLastIndex(m => m.role === 'assistant');
|
| 387 |
+
const lastAi = lastAiIndex !== -1 ? messages[lastAiIndex] : null;
|
| 388 |
+
|
| 389 |
+
if (!lastAi) {
|
| 390 |
+
return (
|
| 391 |
+
<div className="h-full flex flex-col items-center justify-center text-center opacity-50 gap-2">
|
| 392 |
+
<Sparkles size={20} className="animate-pulse text-[#c8860a]" />
|
| 393 |
+
<p className="text-xs">กำลังเตรียมคำตอบ...</p>
|
| 394 |
+
</div>
|
| 395 |
+
);
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
return (
|
| 399 |
+
<div className="flex flex-col items-start w-full">
|
| 400 |
+
{lastAi.thinking && (
|
| 401 |
+
<div className="w-full mb-3 p-3 rounded-xl border-l-2 border-[#c8860a]/30 text-[10.5px] italic opacity-70 bg-black/15 whitespace-pre-wrap">
|
| 402 |
+
<span className="font-bold flex items-center gap-1 mb-1 not-italic text-[#c8860a]/85">
|
| 403 |
+
<Brain size={11} /> กระบวนการคิด...
|
| 404 |
+
</span>
|
| 405 |
+
{lastAi.thinking}
|
| 406 |
+
</div>
|
| 407 |
+
)}
|
| 408 |
+
<div className={`prose prose-sm max-w-none w-full ${proseTheme} prose-headings:text-inherit prose-headings:font-bold prose-headings:mt-3 prose-headings:mb-1 prose-p:my-1.5 prose-ul:my-1.5 prose-li:my-0.5 prose-table:text-xs prose-table:border-collapse prose-th:border prose-th:border-[#c8860a]/30 prose-th:bg-[#c8860a]/10 prose-th:px-2 prose-th:py-1 prose-td:border prose-td:border-[#c8860a]/20 prose-td:px-2 prose-td:py-1 prose-code:bg-[#c8860a]/10 prose-code:px-1 prose-code:rounded prose-pre:bg-black/20 prose-pre:rounded-xl prose-pre:p-3 prose-pre:text-xs prose-strong:text-inherit prose-a:text-[#c8860a] leading-relaxed`}>
|
| 409 |
+
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
| 410 |
+
{lastAi.content || (isStreaming ? '...' : '')}
|
| 411 |
+
</ReactMarkdown>
|
| 412 |
+
</div>
|
| 413 |
+
</div>
|
| 414 |
+
);
|
| 415 |
+
})()}
|
| 416 |
+
</div>
|
| 417 |
+
|
| 418 |
+
{/* ═══════════ Answer Card Footer ═══════════ */}
|
| 419 |
+
<div className={`p-3 border-t flex flex-col gap-2.5 flex-shrink-0 ${ac.border}`}>
|
| 420 |
+
{/* Action Buttons */}
|
| 421 |
+
<div className="flex gap-2 justify-end">
|
| 422 |
+
{(() => {
|
| 423 |
+
const lastAiIdx = messages.findLastIndex(m => m.role === 'assistant');
|
| 424 |
+
return (
|
| 425 |
+
<button
|
| 426 |
+
onClick={() => {
|
| 427 |
+
if (lastAiIdx !== -1) setExpandedIndex(lastAiIdx);
|
| 428 |
+
}}
|
| 429 |
+
disabled={isStreaming || lastAiIdx === -1}
|
| 430 |
+
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-bold border transition-all active:scale-95 disabled:opacity-40 ${ac.btnBg}`}
|
| 431 |
+
>
|
| 432 |
+
<Maximize2 size={13} />
|
| 433 |
+
อ่านเต็มจอ
|
| 434 |
+
</button>
|
| 435 |
+
);
|
| 436 |
+
})()}
|
| 437 |
<button
|
| 438 |
+
onClick={() => setViewMode('chat')}
|
| 439 |
+
disabled={isStreaming}
|
| 440 |
+
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-bold border transition-all active:scale-95 disabled:opacity-40 ${ac.btnBg}`}
|
| 441 |
+
>
|
| 442 |
+
<Send size={13} />
|
| 443 |
+
คุยต่อ
|
| 444 |
+
</button>
|
| 445 |
<button
|
| 446 |
+
onClick={toggleOpen}
|
| 447 |
+
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-bold border transition-all active:scale-95 ${ac.btnBg}`}
|
| 448 |
+
>
|
| 449 |
+
<X size={13} />
|
| 450 |
+
ปิด
|
| 451 |
+
</button>
|
| 452 |
</div>
|
|
|
|
|
|
|
| 453 |
</div>
|
| 454 |
+
</>
|
| 455 |
+
) : (
|
| 456 |
+
<>
|
| 457 |
+
{/* ═══════════ Compact Chat Header ═══════════ */}
|
| 458 |
+
<div
|
| 459 |
+
className="flex flex-col flex-shrink-0 relative overflow-hidden"
|
| 460 |
+
onMouseDown={handleMouseDown}
|
| 461 |
+
style={{ cursor: window.innerWidth >= 768 ? 'grab' : undefined }}
|
| 462 |
>
|
| 463 |
+
{/* Glass background layer */}
|
| 464 |
+
<div className="absolute inset-0 bg-[#c8860a] opacity-90" />
|
| 465 |
+
<div className="absolute inset-0 bg-gradient-to-br from-white/20 to-transparent pointer-events-none" />
|
| 466 |
+
|
| 467 |
+
{/* Row 1: Title + actions */}
|
| 468 |
+
<div className="relative flex items-center justify-between px-3 py-2.5 z-10">
|
| 469 |
+
<div className="flex items-center gap-2.5 min-w-0">
|
| 470 |
+
<div className="w-7 h-7 rounded-lg bg-white/20 flex items-center justify-center shadow-inner">
|
| 471 |
+
<Sparkles size={16} className="text-white" />
|
| 472 |
+
</div>
|
| 473 |
+
<div>
|
| 474 |
+
<h3 className="font-bold text-sm tracking-wide text-white">ผู้ช่วย AI</h3>
|
| 475 |
+
<p className="text-[9px] text-white/70 font-medium uppercase tracking-tighter">Dhamma Assistant</p>
|
| 476 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
</div>
|
| 478 |
+
|
| 479 |
+
{/* Disclaimer in the middle of title bar (Desktop only) */}
|
| 480 |
+
<div className="hidden md:block absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-[9.5px] font-semibold text-[#ffe0a0] max-w-[50%] text-center leading-normal select-none pointer-events-none">
|
| 481 |
+
💡 บทวิเคราะห์นี้ประมวลผลโดย AI ผู้อ่านพึงใช้วิจารณญาณและสอบทานข้อมูลอย่างรอบด้านตามหลักกาลามสูตร
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
</div>
|
| 483 |
+
|
| 484 |
+
<div className="flex items-center gap-1 flex-shrink-0">
|
| 485 |
+
<button onClick={clearHistory} onMouseDown={e => e.stopPropagation()} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors text-white" title="ล้างการสนทนา" aria-label="ล้างการสนทนา"><Trash2 size={14} /></button>
|
| 486 |
+
<button onClick={toggleOpen} onMouseDown={e => e.stopPropagation()} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors text-white" aria-label="ปิด"><X size={16} /></button>
|
| 487 |
</div>
|
| 488 |
</div>
|
| 489 |
+
</div>
|
| 490 |
+
|
| 491 |
+
{/* ═══════════ Settings Bar (Row 2) with high contrast ═══════════ */}
|
| 492 |
+
{(() => {
|
| 493 |
+
const settingsBg = theme === 'dark' ? 'bg-[#15152a]' : theme === 'classic' ? 'bg-[#f4ebe0]' : 'bg-[#ece4cf]';
|
| 494 |
+
const settingsBorder = theme === 'dark' ? 'border-[#3a3a5e]/50' : theme === 'classic' ? 'border-[#d4c4a0]/50' : 'border-[#e0d0b0]/50';
|
| 495 |
+
const settingsText = theme === 'dark' ? 'text-white/80 hover:text-white' : 'text-neutral-700 hover:text-neutral-900';
|
| 496 |
+
return (
|
| 497 |
+
<div className={`flex items-center justify-between px-3 py-1.5 border-b ${settingsBorder} ${settingsBg} ${settingsText} z-10`}>
|
| 498 |
+
{/* RAG Toggle */}
|
| 499 |
+
<button
|
| 500 |
+
onClick={() => setUseRag(!useRag)}
|
| 501 |
+
onMouseDown={e => e.stopPropagation()}
|
| 502 |
+
className="flex items-center gap-1.5 text-[10px] font-semibold transition-colors"
|
| 503 |
+
>
|
| 504 |
+
<span className="flex items-center gap-1.5">
|
| 505 |
+
<span className={`inline-block w-1.5 h-1.5 rounded-full ${ragDot}`} title={
|
| 506 |
+
ragLoading ? 'RAG: กำลังโหลดโมเดล...' : ragReady ? 'RAG พร้อม' : 'RAG ไม่พร้อม'
|
| 507 |
+
} />
|
| 508 |
+
📚 ค้นเล่มอื่น
|
| 509 |
+
</span>
|
| 510 |
+
<span className={`inline-flex items-center px-0.5 w-7 h-3.5 rounded-full transition-colors ${
|
| 511 |
+
useRag
|
| 512 |
+
? 'bg-[#c8860a] justify-end'
|
| 513 |
+
: (theme === 'dark' ? 'bg-white/10 justify-start' : 'bg-black/10 justify-start')
|
| 514 |
+
}`}>
|
| 515 |
+
<span className="w-2.5 h-2.5 bg-white rounded-full shadow-xs" />
|
| 516 |
+
</span>
|
| 517 |
+
</button>
|
| 518 |
+
|
| 519 |
+
{/* Model switcher */}
|
| 520 |
+
<div className="flex bg-black/10 rounded-lg p-0.5 border border-white/5">
|
| 521 |
+
<button
|
| 522 |
+
onClick={() => setMode('fast')}
|
| 523 |
+
onMouseDown={e => e.stopPropagation()}
|
| 524 |
+
className={`flex items-center gap-1 px-2 py-0.5 text-[10px] font-bold uppercase rounded-md transition-all ${
|
| 525 |
+
mode === 'fast' ? 'bg-[#c8860a] text-white shadow-sm' : 'opacity-60 hover:opacity-100'
|
| 526 |
+
}`}
|
| 527 |
+
>
|
| 528 |
+
<Zap size={9} /> เร็ว
|
| 529 |
+
</button>
|
| 530 |
+
<button
|
| 531 |
+
onClick={() => setMode('reasoner')}
|
| 532 |
+
onMouseDown={e => e.stopPropagation()}
|
| 533 |
+
className={`flex items-center gap-1 px-2 py-0.5 text-[10px] font-bold uppercase rounded-md transition-all ${
|
| 534 |
+
mode === 'reasoner' ? 'bg-[#c8860a] text-white shadow-sm' : 'opacity-60 hover:opacity-100'
|
| 535 |
+
}`}
|
| 536 |
+
>
|
| 537 |
+
<Brain size={9} /> คิดลึก
|
| 538 |
+
</button>
|
| 539 |
</div>
|
| 540 |
</div>
|
| 541 |
+
);
|
| 542 |
+
})()}
|
| 543 |
+
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
{/* ═══════════ Messages Area ═══════════ */}
|
| 547 |
+
<div className="relative flex-1 min-h-0 flex flex-col">
|
| 548 |
+
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3 md:p-4 space-y-4">
|
| 549 |
+
{messages.length === 0 && (
|
| 550 |
+
<div className="h-full flex flex-col items-center justify-center text-center opacity-50 px-8 gap-3">
|
| 551 |
+
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${s.msgBg}`}>
|
| 552 |
+
<Sparkles size={24} className="text-[#c8860a]" />
|
| 553 |
+
</div>
|
| 554 |
+
<div>
|
| 555 |
+
<p className="text-sm font-bold mb-0.5">ยินดีต้อนรับ</p>
|
| 556 |
+
<p className="text-xs opacity-60">ลองถามเกี่ยวกับการสรุปหน้าปัจจุบัน หรือคำศัพท์บาลีที่สงสัยดูครับ</p>
|
| 557 |
+
</div>
|
| 558 |
+
</div>
|
| 559 |
+
)}
|
| 560 |
+
|
| 561 |
+
{messages.map((msg, i) => (
|
| 562 |
+
<div key={i} className={`flex flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'} group`}>
|
| 563 |
+
<div className={`relative max-w-[90%] rounded-2xl p-3 text-sm ${
|
| 564 |
+
msg.role === 'user'
|
| 565 |
+
? 'bg-[#c8860a] text-white rounded-tr-none'
|
| 566 |
+
: `${s.msgBg} border ${s.border} text-inherit rounded-tl-none`
|
| 567 |
+
}`}>
|
| 568 |
+
{msg.role === 'assistant' && msg.thinking && (
|
| 569 |
+
<div className={`mb-2 p-2 rounded-lg border-l-2 border-[#c8860a]/30 text-[10px] italic opacity-70 whitespace-pre-wrap ${s.msgBg}`}>
|
| 570 |
+
<span className="font-bold flex items-center gap-1 mb-1 not-italic text-[#c8860a]/60">
|
| 571 |
+
<Brain size={10} /> กำลังคิด...
|
| 572 |
+
</span>
|
| 573 |
+
{msg.thinking}
|
| 574 |
+
</div>
|
| 575 |
+
)}
|
| 576 |
+
<div className={`prose prose-sm max-w-none ${proseTheme} prose-headings:text-inherit prose-headings:font-bold prose-headings:mt-3 prose-headings:mb-1 prose-p:my-1 prose-ul:my-1 prose-li:my-0.5 prose-table:text-xs prose-table:border-collapse prose-th:border prose-th:border-[#c8860a]/30 prose-th:bg-[#c8860a]/10 prose-th:px-2 prose-th:py-1 prose-td:border prose-td:border-[#c8860a]/20 prose-td:px-2 prose-td:py-1 prose-code:bg-[#c8860a]/10 prose-code:px-1 prose-code:rounded prose-pre:bg-black/20 prose-pre:rounded-xl prose-pre:p-3 prose-pre:text-xs prose-strong:text-inherit prose-a:text-[#c8860a] leading-relaxed`}>
|
| 577 |
+
{msg.content || (isStreaming && i === messages.length - 1 ? '...' : '') ? (
|
| 578 |
+
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
| 579 |
+
{msg.content || (isStreaming && i === messages.length - 1 ? '...' : '')}
|
| 580 |
+
</ReactMarkdown>
|
| 581 |
+
) : null}
|
| 582 |
+
</div>
|
| 583 |
+
</div>
|
| 584 |
+
</div>
|
| 585 |
+
))}
|
| 586 |
</div>
|
|
|
|
|
|
|
| 587 |
|
| 588 |
+
{/* Persistent Floating Maximize Button (Visible when scrolled) */}
|
| 589 |
+
{messages.some(m => m.role === 'assistant') && !isStreaming && (
|
| 590 |
+
<div className="absolute bottom-4 right-4 z-10">
|
| 591 |
+
<button
|
| 592 |
+
onClick={() => {
|
| 593 |
+
const lastAi = messages.findLastIndex(m => m.role === 'assistant');
|
| 594 |
+
if (lastAi !== -1) setExpandedIndex(lastAi);
|
| 595 |
+
}}
|
| 596 |
+
className="p-3 bg-white/10 backdrop-blur-md hover:bg-[#c8860a] text-[#c8860a] hover:text-white rounded-2xl shadow-xl transition-all border border-[#c8860a]/20 hover:border-[#c8860a] active:scale-95"
|
| 597 |
+
title="อ่านแบบเต็มจอ"
|
| 598 |
+
>
|
| 599 |
+
<Maximize2 size={20} />
|
| 600 |
+
</button>
|
| 601 |
+
</div>
|
| 602 |
+
)}
|
| 603 |
</div>
|
| 604 |
+
|
| 605 |
+
{/* ═══════════ Input ═══════════ */}
|
| 606 |
+
<form onSubmit={handleSubmit} className={`p-3 md:p-4 border-t ${s.border} flex gap-2 flex-shrink-0 ${s.bg}`}>
|
| 607 |
+
<div className="relative flex-1">
|
| 608 |
+
<input type="text" value={input} onChange={(e) => setInput(e.target.value)}
|
| 609 |
+
placeholder="ถามเกี่ยวกับพระธรรมในหน้านี้..." disabled={isStreaming}
|
| 610 |
+
className={`w-full pl-4 pr-12 py-2.5 rounded-2xl outline-none text-sm disabled:opacity-50 border shadow-inner transition-all ${s.border} ${s.msgBg} ${s.text} focus:border-[#c8860a] focus:ring-2 focus:ring-[#c8860a]/20`}
|
| 611 |
+
/>
|
| 612 |
+
<button type="submit" disabled={!input.trim() || isStreaming}
|
| 613 |
+
className="absolute right-1.5 top-1.5 p-2 bg-[#c8860a] text-white rounded-xl disabled:opacity-50 hover:bg-[#9a6307] transition-all shadow-md active:scale-95"
|
| 614 |
+
>
|
| 615 |
+
<Send size={14} />
|
| 616 |
+
</button>
|
| 617 |
+
</div>
|
| 618 |
+
</form>
|
| 619 |
+
</>
|
| 620 |
+
)}
|
| 621 |
</motion.div>
|
| 622 |
</>
|
| 623 |
)}
|
webapp/tipitaka-web/src/components/layout/AppShell.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import ReaderPanel from './ReaderPanel';
|
|
| 4 |
import RightToolbar from '../toolbar/RightToolbar';
|
| 5 |
import MobileBottomBar from './MobileBottomBar';
|
| 6 |
import AIPopup from '../ai/AIPopup';
|
|
|
|
| 7 |
import ErrorBoundary from '../common/ErrorBoundary';
|
| 8 |
import { Menu } from 'lucide-react';
|
| 9 |
import { useUIStore, useThemeStore } from '../../stores/appStore';
|
|
@@ -27,7 +28,7 @@ const MENU_BTN: Record<string, string> = {
|
|
| 27 |
|
| 28 |
const AppShell: React.FC = () => {
|
| 29 |
const { toggleNav, isNavOpen } = useUIStore();
|
| 30 |
-
const {
|
| 31 |
const { theme } = useThemeStore();
|
| 32 |
|
| 33 |
// Keyboard shortcut: ← → PgUp PgDown Space for page navigation
|
|
@@ -93,16 +94,10 @@ const AppShell: React.FC = () => {
|
|
| 93 |
{/* Mobile: bottom navigation bar (hidden on desktop) */}
|
| 94 |
<MobileBottomBar />
|
| 95 |
|
| 96 |
-
{/* AI
|
| 97 |
-
<
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
isOpen ? 'scale-0 opacity-0 pointer-events-none' : 'scale-100 opacity-100'
|
| 101 |
-
}`}
|
| 102 |
-
title="ผู้ช่วย AI"
|
| 103 |
-
>
|
| 104 |
-
✨
|
| 105 |
-
</button>
|
| 106 |
|
| 107 |
{/* AI Popup */}
|
| 108 |
<AnimatePresence>
|
|
|
|
| 4 |
import RightToolbar from '../toolbar/RightToolbar';
|
| 5 |
import MobileBottomBar from './MobileBottomBar';
|
| 6 |
import AIPopup from '../ai/AIPopup';
|
| 7 |
+
import AIFloatingMenu from '../ai/AIFloatingMenu';
|
| 8 |
import ErrorBoundary from '../common/ErrorBoundary';
|
| 9 |
import { Menu } from 'lucide-react';
|
| 10 |
import { useUIStore, useThemeStore } from '../../stores/appStore';
|
|
|
|
| 28 |
|
| 29 |
const AppShell: React.FC = () => {
|
| 30 |
const { toggleNav, isNavOpen } = useUIStore();
|
| 31 |
+
const { isOpen } = useAIStore();
|
| 32 |
const { theme } = useThemeStore();
|
| 33 |
|
| 34 |
// Keyboard shortcut: ← → PgUp PgDown Space for page navigation
|
|
|
|
| 94 |
{/* Mobile: bottom navigation bar (hidden on desktop) */}
|
| 95 |
<MobileBottomBar />
|
| 96 |
|
| 97 |
+
{/* AI Floating Menu */}
|
| 98 |
+
<ErrorBoundary componentName="AIFloatingMenu">
|
| 99 |
+
<AIFloatingMenu />
|
| 100 |
+
</ErrorBoundary>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
{/* AI Popup */}
|
| 103 |
<AnimatePresence>
|
webapp/tipitaka-web/src/stores/aiStore.ts
CHANGED
|
@@ -16,11 +16,13 @@ interface AIState {
|
|
| 16 |
ragChecking: boolean; // true while checking status
|
| 17 |
ragLoading: boolean; // true when Qdrant exists but Reranker still loading
|
| 18 |
dragPos: { x: number; y: number } | null; // saved drag position (desktop)
|
|
|
|
| 19 |
panelWidth: number; // saved panel width (desktop)
|
| 20 |
panelHeight: number; // saved panel height (desktop)
|
| 21 |
|
| 22 |
// Actions
|
| 23 |
toggleOpen: () => void;
|
|
|
|
| 24 |
setMode: (mode: 'fast' | 'reasoner') => void;
|
| 25 |
setUseRag: (use: boolean) => void;
|
| 26 |
addMessage: (msg: Message) => void;
|
|
@@ -44,10 +46,12 @@ export const useAIStore = create<AIState>((set, get) => ({
|
|
| 44 |
ragChecking: false,
|
| 45 |
ragLoading: false,
|
| 46 |
dragPos: null,
|
|
|
|
| 47 |
panelWidth: 400,
|
| 48 |
panelHeight: 550,
|
| 49 |
|
| 50 |
toggleOpen: () => set((state) => ({ isOpen: !state.isOpen })),
|
|
|
|
| 51 |
setMode: (mode) => set({ mode }),
|
| 52 |
setUseRag: (useRag) => set({ useRag }),
|
| 53 |
addMessage: (msg) => set((state) => ({ messages: [...state.messages, msg] })),
|
|
|
|
| 16 |
ragChecking: boolean; // true while checking status
|
| 17 |
ragLoading: boolean; // true when Qdrant exists but Reranker still loading
|
| 18 |
dragPos: { x: number; y: number } | null; // saved drag position (desktop)
|
| 19 |
+
viewMode: 'chat' | 'answer';
|
| 20 |
panelWidth: number; // saved panel width (desktop)
|
| 21 |
panelHeight: number; // saved panel height (desktop)
|
| 22 |
|
| 23 |
// Actions
|
| 24 |
toggleOpen: () => void;
|
| 25 |
+
setViewMode: (mode: 'chat' | 'answer') => void;
|
| 26 |
setMode: (mode: 'fast' | 'reasoner') => void;
|
| 27 |
setUseRag: (use: boolean) => void;
|
| 28 |
addMessage: (msg: Message) => void;
|
|
|
|
| 46 |
ragChecking: false,
|
| 47 |
ragLoading: false,
|
| 48 |
dragPos: null,
|
| 49 |
+
viewMode: 'chat',
|
| 50 |
panelWidth: 400,
|
| 51 |
panelHeight: 550,
|
| 52 |
|
| 53 |
toggleOpen: () => set((state) => ({ isOpen: !state.isOpen })),
|
| 54 |
+
setViewMode: (viewMode) => set({ viewMode }),
|
| 55 |
setMode: (mode) => set({ mode }),
|
| 56 |
setUseRag: (useRag) => set({ useRag }),
|
| 57 |
addMessage: (msg) => set((state) => ({ messages: [...state.messages, msg] })),
|