dhammawatthumpra commited on
Commit
59e308f
·
1 Parent(s): 75ee5dc

fix: RAG Qwen embedding function for ChromaDB 1.5.7; feat: draggable AI popup + RAG status indicator (green/yellow/red)

Browse files
webapp/tipitaka-api/app/routers/ai.py CHANGED
@@ -3,6 +3,11 @@ from fastapi import APIRouter, Depends
3
  from sse_starlette.sse import EventSourceResponse
4
  from app.services.llm_service import LLMService, AskRequest
5
  from app.database.sqlite_db import get_db, SQLiteDB
 
 
 
 
 
6
 
7
  router = APIRouter(prefix="/ask", tags=["AI"])
8
 
@@ -17,7 +22,35 @@ async def ask_stream(request: AskRequest, service: LLMService = Depends(get_llm_
17
 
18
 
19
  @router.get("/rag-status")
20
- async def rag_status(service: LLMService = Depends(get_llm_service)):
21
- """Check whether RAG (Qwen embedding + ChromaDB) is loaded and ready."""
22
- ready = service.rag_service.collection is not None if service.rag_service else False
23
- return {"ready": ready, "model": "Qwen3-Embedding-0.6B"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  from sse_starlette.sse import EventSourceResponse
4
  from app.services.llm_service import LLMService, AskRequest
5
  from app.database.sqlite_db import get_db, SQLiteDB
6
+ from app.config import get_settings
7
+ import chromadb
8
+ import logging
9
+
10
+ logger = logging.getLogger(__name__)
11
 
12
  router = APIRouter(prefix="/ask", tags=["AI"])
13
 
 
22
 
23
 
24
  @router.get("/rag-status")
25
+ async def rag_status():
26
+ """
27
+ Check whether RAG (ChromaDB + Qwen) is ready.
28
+ Lightweight does NOT trigger the full Qwen model load.
29
+ Uses the LLMService singleton if already created, otherwise
30
+ checks the ChromaDB collection directly.
31
+ """
32
+ # First check if service was already initialized (avoids triggering model load)
33
+ try:
34
+ service = get_llm_service()
35
+ if service.rag_service.collection is not None:
36
+ return {"ready": True, "loading": False}
37
+ # Collection exists but model failed — check ChromaDB directly
38
+ except Exception:
39
+ pass
40
+
41
+ # Lightweight: check ChromaDB collection exists without loading embedding model
42
+ try:
43
+ settings = get_settings()
44
+ client = chromadb.PersistentClient(path=settings.CHROMA_PERSIST_PATH)
45
+ collections = client.list_collections()
46
+ names = [c.name for c in collections]
47
+ if "tipitaka_mcu_qwen" in names:
48
+ # Collection exists but needs full model load — return loading state
49
+ return {"ready": False, "loading": True,
50
+ "message": "RAG database found — model loading on first query"}
51
+ else:
52
+ return {"ready": False, "loading": False,
53
+ "message": "No ChromaDB collection found"}
54
+ except Exception as e:
55
+ logger.warning(f"RAG status check failed: {e}")
56
+ return {"ready": False, "loading": False, "error": str(e)}
webapp/tipitaka-api/app/services/rag_service.py CHANGED
@@ -8,48 +8,61 @@ class QwenEmbeddingFunction:
8
  def __init__(self, model_name: str):
9
  self.device = "cuda" if torch.cuda.is_available() else "cpu"
10
  self.model = SentenceTransformer(model_name, trust_remote_code=True, device=self.device)
11
-
12
- def __call__(self, input: list[str]) -> list[list[float]]:
 
 
 
13
  embeddings = self.model.encode(
14
- input,
15
- batch_size=32,
16
- show_progress_bar=False,
17
- normalize_embeddings=True,
18
- convert_to_numpy=True
19
  )
20
  return embeddings.tolist()
21
 
 
 
 
 
 
 
 
 
 
22
  class RAGService:
23
  def __init__(self):
24
  settings = get_settings()
25
  self.persist_path = settings.CHROMA_PERSIST_PATH
26
-
27
  # Initialize ChromaDB client
28
  self.client = chromadb.PersistentClient(path=self.persist_path)
29
-
30
- # Use Qwen embedding model as in v2.1
31
  try:
 
32
  self.embedding_fn = QwenEmbeddingFunction("Qwen/Qwen3-Embedding-0.6B")
33
  self.collection = self.client.get_collection(
34
- name="tipitaka_mcu_qwen",
35
- embedding_function=self.embedding_fn
36
  )
37
  except Exception as e:
38
  print(f"RAG Initialization Error: {e}")
39
  self.collection = None
 
40
 
41
  async def query(self, text: str, n_results: int = 3, threshold: float = 0.55) -> str:
42
  """
43
  Query ChromaDB for relevant chunks and return formatted context.
44
  Uses the distance threshold logic from tipitaka_app_v2.1.py
45
  """
46
- if not self.collection:
47
  return ""
48
 
49
  results = self.collection.query(
50
  query_texts=[text],
51
  n_results=n_results,
52
- include=["documents", "metadatas", "distances"]
53
  )
54
 
55
  context_parts = []
 
8
  def __init__(self, model_name: str):
9
  self.device = "cuda" if torch.cuda.is_available() else "cpu"
10
  self.model = SentenceTransformer(model_name, trust_remote_code=True, device=self.device)
11
+
12
+ def name(self) -> str:
13
+ return "Qwen3-Embedding-0.6B"
14
+
15
+ def _encode(self, input: list[str]) -> list[list[float]]:
16
  embeddings = self.model.encode(
17
+ input,
18
+ batch_size=32,
19
+ show_progress_bar=False,
20
+ normalize_embeddings=True,
21
+ convert_to_numpy=True,
22
  )
23
  return embeddings.tolist()
24
 
25
+ def embed_query(self, input: list[str]) -> list[list[float]]:
26
+ return self._encode(input)
27
+
28
+ def embed_documents(self, input: list[str]) -> list[list[float]]:
29
+ return self._encode(input)
30
+
31
+ def __call__(self, input: list[str]) -> list[list[float]]:
32
+ return self._encode(input)
33
+
34
  class RAGService:
35
  def __init__(self):
36
  settings = get_settings()
37
  self.persist_path = settings.CHROMA_PERSIST_PATH
38
+
39
  # Initialize ChromaDB client
40
  self.client = chromadb.PersistentClient(path=self.persist_path)
41
+
 
42
  try:
43
+ # Load Qwen model first, then get collection with embedding function
44
  self.embedding_fn = QwenEmbeddingFunction("Qwen/Qwen3-Embedding-0.6B")
45
  self.collection = self.client.get_collection(
46
+ name="tipitaka_mcu_qwen",
47
+ embedding_function=self.embedding_fn,
48
  )
49
  except Exception as e:
50
  print(f"RAG Initialization Error: {e}")
51
  self.collection = None
52
+ self.embedding_fn = None
53
 
54
  async def query(self, text: str, n_results: int = 3, threshold: float = 0.55) -> str:
55
  """
56
  Query ChromaDB for relevant chunks and return formatted context.
57
  Uses the distance threshold logic from tipitaka_app_v2.1.py
58
  """
59
+ if not self.collection or not self.embedding_fn:
60
  return ""
61
 
62
  results = self.collection.query(
63
  query_texts=[text],
64
  n_results=n_results,
65
+ include=["documents", "metadatas", "distances"],
66
  )
67
 
68
  context_parts = []
webapp/tipitaka-web/src/components/ai/AIPopup.tsx CHANGED
@@ -1,10 +1,9 @@
1
- import React, { useState, useRef, useEffect } from 'react';
2
  import { useAIStore } from '../../stores/aiStore';
3
  import { useReaderStore, useThemeStore } from '../../stores/appStore';
4
  import { X, Send, Sparkles, Trash2, Zap, Brain, Loader2 } from 'lucide-react';
5
  import { motion } from 'framer-motion';
6
 
7
- // ── AI Panel theme ────────────────────────────────────────────
8
  const PANEL_STYLES: Record<string, { bg: string; text: string; border: string; msgBg: string }> = {
9
  dark: { bg: 'bg-[#0f0f1e]', text: 'text-[#e0e0e0]', border: 'border-[#3a3a5e]', msgBg: 'bg-[#1a1a2e]' },
10
  light: { bg: 'bg-[#f5edd8]', text: 'text-[#1a1a1a]', border: 'border-[#e0d0b0]', msgBg: 'bg-[#fdfaf5]' },
@@ -15,7 +14,8 @@ const AIPopup: React.FC = () => {
15
  const {
16
  isOpen, toggleOpen, messages, isStreaming,
17
  askAI, clearHistory, mode, setMode,
18
- useRag, setUseRag, checkRagStatus, ragReady, ragChecking
 
19
  } = useAIStore();
20
 
21
  const { currentVolume, currentPage, currentContent } = useReaderStore();
@@ -23,6 +23,54 @@ const AIPopup: React.FC = () => {
23
  const [input, setInput] = useState('');
24
  const [showQuickPrompts, setShowQuickPrompts] = useState(true);
25
  const scrollRef = useRef<HTMLDivElement>(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
  const s = PANEL_STYLES[theme] ?? PANEL_STYLES.dark;
28
 
@@ -47,22 +95,48 @@ const AIPopup: React.FC = () => {
47
 
48
  if (!isOpen) return null;
49
 
 
 
 
 
 
 
 
 
 
50
  return (
51
  <motion.div
 
52
  initial={{ opacity: 0, scale: 0.95, y: 20 }}
53
- animate={{ opacity: 1, scale: 1, y: 0 }}
 
 
 
 
 
54
  exit={{ opacity: 0, scale: 0.95, y: 20 }}
55
- // Mobile: centered via left-1/2 -translate-x-1/2
56
- // Desktop (md+): right-6, fixed 400px
57
- className={`fixed bottom-24 left-1/2 -translate-x-1/2
58
- md:left-auto md:right-14 md:translate-x-0
59
- w-[calc(100vw-32px)] md:w-[400px]
60
- max-h-[80vh] md:h-[550px]
61
- z-50 flex flex-col overflow-hidden rounded-2xl border
62
- shadow-2xl ${s.bg} ${s.text} ${s.border}`}
 
 
 
 
 
 
 
63
  >
64
- {/* ═══════════ Compact Header + Mode Pills ═══════════ */}
65
- <div className="flex flex-col flex-shrink-0 bg-[#c8860a] text-white">
 
 
 
 
66
  {/* Row 1: Title + actions */}
67
  <div className="flex items-center justify-between px-3 py-2">
68
  <div className="flex items-center gap-2 min-w-0">
@@ -70,107 +144,65 @@ const AIPopup: React.FC = () => {
70
  <h3 className="font-bold text-sm tracking-wide truncate">ผู้ช่วย AI</h3>
71
  </div>
72
  <div className="flex items-center gap-1 flex-shrink-0">
73
- {/* Mode pills inline */}
74
  <div className="flex bg-black/15 rounded-lg p-0.5 mr-1">
75
  <button
76
  onClick={() => setMode('fast')}
77
  className={`flex items-center gap-1 px-2 py-1 text-[10px] font-bold uppercase rounded-md transition-all ${
78
  mode === 'fast' ? 'bg-white/20 text-white' : 'text-white/60 hover:text-white/90'
79
  }`}
80
- >
81
- <Zap size={10} /> เร็ว
82
- </button>
83
  <button
84
  onClick={() => setMode('reasoner')}
85
  className={`flex items-center gap-1 px-2 py-1 text-[10px] font-bold uppercase rounded-md transition-all ${
86
  mode === 'reasoner' ? 'bg-white/20 text-white' : 'text-white/60 hover:text-white/90'
87
  }`}
88
- >
89
- <Brain size={10} /> คิดลึก
90
- </button>
91
  </div>
92
- <button
93
- onClick={clearHistory}
94
- className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"
95
- title="ล้างการสนทนา"
96
- aria-label="ล้างการสนทนา"
97
- >
98
- <Trash2 size={14} />
99
- </button>
100
- <button
101
- onClick={toggleOpen}
102
- className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"
103
- aria-label="ปิด"
104
- >
105
- <X size={16} />
106
- </button>
107
  </div>
108
  </div>
109
 
110
- {/* Row 2: RAG toggle + Quick prompt toggle — merged thin bar */}
111
- <div className={`flex items-center justify-between px-3 py-1 border-t border-white/10`}>
112
- {/* RAG Toggle */}
113
  <button
114
  onClick={() => setUseRag(!useRag)}
115
  className="flex items-center gap-1.5 text-[10px] font-medium text-white/60 hover:text-white transition-colors"
116
  >
117
  <span className="flex items-center gap-1">
118
- {/* Status dot */}
119
- {ragChecking ? (
120
- <Loader2 size={8} className="animate-spin text-white/40" />
121
- ) : (
122
- <span
123
- className={`inline-block w-1.5 h-1.5 rounded-full ${
124
- ragReady ? 'bg-green-400' : 'bg-red-400'
125
- }`}
126
- title={ragReady ? 'RAG พร้อมใช้งาน' : 'RAG ไม่พร้อม — Qwen model อาจโหลดไม่สำเร็จ'}
127
- />
128
- )}
129
  📚 ค้นเล่มอื่น
130
  </span>
131
- <span
132
- className={`inline-flex items-center px-0.5 w-7 h-3.5 rounded-full transition-colors ${
133
- useRag ? 'bg-white/50 justify-end' : 'bg-white/20 justify-start'
134
- }`}
135
- >
136
  <span className="w-2.5 h-2.5 bg-white rounded-full shadow-xs" />
137
  </span>
138
  </button>
139
-
140
- {/* Quick Prompt toggle */}
141
  <button
142
  onClick={() => setShowQuickPrompts(v => !v)}
143
  className="text-[10px] font-bold text-white/50 hover:text-white transition-colors"
144
- >
145
- {showQuickPrompts ? '▲ ซ่อนปุ่มลัด' : '▼ ปุ่มลัด'}
146
- </button>
147
  </div>
148
  </div>
149
 
150
- {/* ═══════════ Quick Prompt Buttons (collapsible) ═══════════ */}
151
  <div className={`grid grid-cols-2 gap-1.5 px-3 overflow-hidden transition-all duration-200 ${
152
  showQuickPrompts ? 'py-2 max-h-32 border-b opacity-100' : 'max-h-0 border-transparent opacity-0'
153
  } ${showQuickPrompts ? s.border : ''}`}>
154
- <button
155
- onClick={() => { const c = `เล่มที่ ${currentVolume} หน้าที่ ${currentPage}\nเนื้า:\n${currentContent}`; askAI('ช่วยอธิบายเนื้อหานี้ให้เข้าใจง่ายขึ้น', c); }}
156
- disabled={isStreaming}
157
- 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`}
158
- >💬 ธิบา</button>
159
- <button
160
- onClick={() => { const c = `เล่มที่ ${currentVolume} หน้าที่ ${currentPage}\nเนื้อหา:\n${currentContent}`; askAI('ช่วยสรุปใจความสำคัญของเนื้อหานี้เป็นข้อๆ', c); }}
161
- disabled={isStreaming}
162
- 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`}
163
- >📝 สรุป</button>
164
- <button
165
- onClick={() => { const c = `เล่มที่ ${currentVolume} หน้าที่ ${currentPage}\nเนื้อหา:\n${currentContent}`; askAI('ช่วยวิเคราะห์หลักธรรมที่ปรากฏในเนื้อหานี้', c); }}
166
- disabled={isStreaming}
167
- 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`}
168
- >⚖️ วิเคราะห์ธรรม</button>
169
- <button
170
- onClick={() => { const c = `เล่มที่ ${currentVolume} หน้าที่ ${currentPage}\nเนื้อหา:\n${currentContent}`; askAI('หลักธรรมนี้ประยุกต์ใช้ในชีวิตประจำวันได้อย่างไร', c); }}
171
- disabled={isStreaming}
172
- 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`}
173
- >💡 ประยุกต์ใช้</button>
174
  </div>
175
 
176
  {/* ═══════════ Messages ══════════�� */}
@@ -211,25 +243,14 @@ const AIPopup: React.FC = () => {
211
  </div>
212
 
213
  {/* ═══════════ Input ═══════════ */}
214
- <form
215
- onSubmit={handleSubmit}
216
- className={`p-2 md:p-3 border-t ${s.border} flex gap-2 flex-shrink-0 ${s.bg}`}
217
- >
218
- <input
219
- type="text"
220
- value={input}
221
- onChange={(e) => setInput(e.target.value)}
222
- placeholder="ถาม AI ได้เลย..."
223
- disabled={isStreaming}
224
  className={`flex-1 px-3 py-2 rounded-2xl outline-none text-sm disabled:opacity-50 border ${s.border} ${s.msgBg} ${s.text} focus:border-[#c8860a] transition-colors`}
225
  />
226
- <button
227
- type="submit"
228
- disabled={!input.trim() || isStreaming}
229
  className="p-2 bg-[#c8860a] text-white rounded-full disabled:opacity-50 hover:bg-[#9a6307] transition-colors flex-shrink-0"
230
- >
231
- <Send size={16} />
232
- </button>
233
  </form>
234
  </motion.div>
235
  );
 
1
+ import React, { useState, useRef, useEffect, useCallback } from 'react';
2
  import { useAIStore } from '../../stores/aiStore';
3
  import { useReaderStore, useThemeStore } from '../../stores/appStore';
4
  import { X, Send, Sparkles, Trash2, Zap, Brain, Loader2 } from 'lucide-react';
5
  import { motion } from 'framer-motion';
6
 
 
7
  const PANEL_STYLES: Record<string, { bg: string; text: string; border: string; msgBg: string }> = {
8
  dark: { bg: 'bg-[#0f0f1e]', text: 'text-[#e0e0e0]', border: 'border-[#3a3a5e]', msgBg: 'bg-[#1a1a2e]' },
9
  light: { bg: 'bg-[#f5edd8]', text: 'text-[#1a1a1a]', border: 'border-[#e0d0b0]', msgBg: 'bg-[#fdfaf5]' },
 
14
  const {
15
  isOpen, toggleOpen, messages, isStreaming,
16
  askAI, clearHistory, mode, setMode,
17
+ useRag, setUseRag, checkRagStatus, ragReady, ragChecking, ragLoading,
18
+ dragPos, setDragPos,
19
  } = useAIStore();
20
 
21
  const { currentVolume, currentPage, currentContent } = useReaderStore();
 
23
  const [input, setInput] = useState('');
24
  const [showQuickPrompts, setShowQuickPrompts] = useState(true);
25
  const scrollRef = useRef<HTMLDivElement>(null);
26
+ const panelRef = useRef<HTMLDivElement>(null);
27
+
28
+ // ── Drag state ──────────────────────────────────────────────
29
+ const [isDragging, setIsDragging] = useState(false);
30
+ const dragOffset = useRef({ x: 0, y: 0 });
31
+ const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
32
+
33
+ // Restore saved position on mount
34
+ useEffect(() => {
35
+ if (dragPos) setPos(dragPos);
36
+ }, []);
37
+
38
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
39
+ // Only desktop — ignore if touch device or small screen
40
+ if (window.innerWidth < 768) return;
41
+
42
+ setIsDragging(true);
43
+ const rect = panelRef.current?.getBoundingClientRect();
44
+ if (rect) {
45
+ dragOffset.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
46
+ }
47
+ e.preventDefault();
48
+ }, []);
49
+
50
+ useEffect(() => {
51
+ if (!isDragging) return;
52
+
53
+ const onMove = (e: MouseEvent) => {
54
+ const x = e.clientX - dragOffset.current.x;
55
+ const y = e.clientY - dragOffset.current.y;
56
+ // Clamp to viewport
57
+ const clampedX = Math.max(0, Math.min(x, window.innerWidth - 320));
58
+ const clampedY = Math.max(0, Math.min(y, window.innerHeight - 200));
59
+ setPos({ x: clampedX, y: clampedY });
60
+ };
61
+
62
+ const onUp = () => {
63
+ setIsDragging(false);
64
+ if (pos) setDragPos(pos);
65
+ };
66
+
67
+ document.addEventListener('mousemove', onMove);
68
+ document.addEventListener('mouseup', onUp);
69
+ return () => {
70
+ document.removeEventListener('mousemove', onMove);
71
+ document.removeEventListener('mouseup', onUp);
72
+ };
73
+ }, [isDragging, pos, setDragPos]);
74
 
75
  const s = PANEL_STYLES[theme] ?? PANEL_STYLES.dark;
76
 
 
95
 
96
  if (!isOpen) return null;
97
 
98
+ // RAG status dot color
99
+ const ragDot = ragChecking
100
+ ? 'bg-yellow-400 animate-pulse' // ⏳ กำลังตรวจสอบ
101
+ : ragLoading
102
+ ? 'bg-yellow-400' // 🟡 DB พร้อม รอโมเดล
103
+ : ragReady
104
+ ? 'bg-green-400' // 🟢 พร้อม
105
+ : 'bg-red-400'; // 🔴 ไม่พร้อม
106
+
107
  return (
108
  <motion.div
109
+ ref={panelRef}
110
  initial={{ opacity: 0, scale: 0.95, y: 20 }}
111
+ animate={{
112
+ opacity: 1, scale: 1, y: 0,
113
+ ...(pos && window.innerWidth >= 768
114
+ ? { left: pos.x, top: pos.y, right: 'auto', bottom: 'auto' }
115
+ : {}),
116
+ }}
117
  exit={{ opacity: 0, scale: 0.95, y: 20 }}
118
+ // Mobile: centered. Desktop: positioned by state or default bottom-right
119
+ className={`
120
+ fixed z-50 flex flex-col overflow-hidden rounded-2xl border shadow-2xl
121
+ ${s.bg} ${s.text} ${s.border}
122
+ ${pos || window.innerWidth >= 768 ? '' : 'bottom-24 left-1/2 -translate-x-1/2'}
123
+ ${pos ? '' : 'md:left-auto md:right-14 md:translate-x-0'}
124
+ w-[calc(100vw-32px)] md:w-[400px]
125
+ max-h-[80vh] md:h-[550px]
126
+ ${isDragging ? 'cursor-grabbing select-none' : ''}
127
+ `}
128
+ style={pos && window.innerWidth >= 768 ? {
129
+ position: 'fixed',
130
+ left: pos.x,
131
+ top: pos.y,
132
+ } : undefined}
133
  >
134
+ {/* ═══════════ Compact Header + Drag Handle ═══════════ */}
135
+ <div
136
+ className="flex flex-col flex-shrink-0 bg-[#c8860a] text-white"
137
+ onMouseDown={handleMouseDown}
138
+ style={{ cursor: window.innerWidth >= 768 ? 'grab' : undefined }}
139
+ >
140
  {/* Row 1: Title + actions */}
141
  <div className="flex items-center justify-between px-3 py-2">
142
  <div className="flex items-center gap-2 min-w-0">
 
144
  <h3 className="font-bold text-sm tracking-wide truncate">ผู้ช่วย AI</h3>
145
  </div>
146
  <div className="flex items-center gap-1 flex-shrink-0">
 
147
  <div className="flex bg-black/15 rounded-lg p-0.5 mr-1">
148
  <button
149
  onClick={() => setMode('fast')}
150
  className={`flex items-center gap-1 px-2 py-1 text-[10px] font-bold uppercase rounded-md transition-all ${
151
  mode === 'fast' ? 'bg-white/20 text-white' : 'text-white/60 hover:text-white/90'
152
  }`}
153
+ ><Zap size={10} /> เร็ว</button>
 
 
154
  <button
155
  onClick={() => setMode('reasoner')}
156
  className={`flex items-center gap-1 px-2 py-1 text-[10px] font-bold uppercase rounded-md transition-all ${
157
  mode === 'reasoner' ? 'bg-white/20 text-white' : 'text-white/60 hover:text-white/90'
158
  }`}
159
+ ><Brain size={10} /> คิดลึก</button>
 
 
160
  </div>
161
+ <button onClick={clearHistory} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors" title="ล้างการสนทนา" aria-label="ล้างการสนทนา"><Trash2 size={14} /></button>
162
+ <button onClick={toggleOpen} className="p-1.5 hover:bg-white/20 rounded-lg transition-colors" aria-label="ปิด"><X size={16} /></button>
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  </div>
164
  </div>
165
 
166
+ {/* Row 2: RAG toggle + Quick prompt toggle */}
167
+ <div className="flex items-center justify-between px-3 py-1 border-t border-white/10">
 
168
  <button
169
  onClick={() => setUseRag(!useRag)}
170
  className="flex items-center gap-1.5 text-[10px] font-medium text-white/60 hover:text-white transition-colors"
171
  >
172
  <span className="flex items-center gap-1">
173
+ <span className={`inline-block w-1.5 h-1.5 rounded-full ${ragDot}`} title={
174
+ ragLoading ? 'RAG: กำลังโหลดโมเดล...' : ragReady ? 'RAG พร้อม' : 'RAG ไม่พร้อม'
175
+ } />
 
 
 
 
 
 
 
 
176
  📚 ค้นเล่มอื่น
177
  </span>
178
+ <span className={`inline-flex items-center px-0.5 w-7 h-3.5 rounded-full transition-colors ${
179
+ useRag ? 'bg-white/50 justify-end' : 'bg-white/20 justify-start'
180
+ }`}>
 
 
181
  <span className="w-2.5 h-2.5 bg-white rounded-full shadow-xs" />
182
  </span>
183
  </button>
 
 
184
  <button
185
  onClick={() => setShowQuickPrompts(v => !v)}
186
  className="text-[10px] font-bold text-white/50 hover:text-white transition-colors"
187
+ >{showQuickPrompts ? '▲ ซ่อนปุ่มลัด' : '▼ ปุ่มลัด'}</button>
 
 
188
  </div>
189
  </div>
190
 
191
+ {/* ═══════════ Quick Prompt Buttons ═══════════ */}
192
  <div className={`grid grid-cols-2 gap-1.5 px-3 overflow-hidden transition-all duration-200 ${
193
  showQuickPrompts ? 'py-2 max-h-32 border-b opacity-100' : 'max-h-0 border-transparent opacity-0'
194
  } ${showQuickPrompts ? s.border : ''}`}>
195
+ {[
196
+ { emoji: '💬', text: 'ธิบย', prompt: 'ช่วยอธิบายเนื้อหานี้ให้เข้าใจง่ายขึ้น' },
197
+ { emoji: '📝', text: 'สรุป', prompt: 'ช่วยสรุปใจความสำคัญของเนื้อหานี้เป็นข้อๆ' },
198
+ { emoji: '⚖️', text: 'วิเคราะห์ธรรม', prompt: 'ช่วยวิเคราะห์หลักธรรมที่ปรากฏในเนื้อหานี้' },
199
+ { emoji: '💡', text: 'ประยุกต์ใช้', prompt: 'หลักรรมนี้ประยุกต์ใช้ในชีวตประจำวันได้อ่างไร' },
200
+ ].map(({ emoji, text, prompt }) => (
201
+ <button key={text} onClick={() => { const c = `เล่มที่ ${currentVolume} หน้าที่ ${currentPage}\nเนื้อหา:\n${currentContent}`; askAI(prompt, c); }}
202
+ disabled={isStreaming}
203
+ 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`}
204
+ >{emoji} {text}</button>
205
+ ))}
 
 
 
 
 
 
 
 
 
206
  </div>
207
 
208
  {/* ═══════════ Messages ══════════�� */}
 
243
  </div>
244
 
245
  {/* ═══════════ Input ═══════════ */}
246
+ <form onSubmit={handleSubmit} className={`p-2 md:p-3 border-t ${s.border} flex gap-2 flex-shrink-0 ${s.bg}`}>
247
+ <input type="text" value={input} onChange={(e) => setInput(e.target.value)}
248
+ placeholder="ถาม AI ได้เลย..." disabled={isStreaming}
 
 
 
 
 
 
 
249
  className={`flex-1 px-3 py-2 rounded-2xl outline-none text-sm disabled:opacity-50 border ${s.border} ${s.msgBg} ${s.text} focus:border-[#c8860a] transition-colors`}
250
  />
251
+ <button type="submit" disabled={!input.trim() || isStreaming}
 
 
252
  className="p-2 bg-[#c8860a] text-white rounded-full disabled:opacity-50 hover:bg-[#9a6307] transition-colors flex-shrink-0"
253
+ ><Send size={16} /></button>
 
 
254
  </form>
255
  </motion.div>
256
  );
webapp/tipitaka-web/src/stores/aiStore.ts CHANGED
@@ -14,6 +14,8 @@ interface AIState {
14
  useRag: boolean;
15
  ragReady: boolean; // true when Qwen + ChromaDB loaded
16
  ragChecking: boolean; // true while checking status
 
 
17
 
18
  // Actions
19
  toggleOpen: () => void;
@@ -21,6 +23,7 @@ interface AIState {
21
  setUseRag: (use: boolean) => void;
22
  addMessage: (msg: Message) => void;
23
  clearHistory: () => void;
 
24
 
25
  // SSE Streaming logic
26
  askAI: (question: string, context: string) => Promise<void>;
@@ -35,6 +38,8 @@ export const useAIStore = create<AIState>((set, get) => ({
35
  useRag: false,
36
  ragReady: false,
37
  ragChecking: false,
 
 
38
 
39
  toggleOpen: () => set((state) => ({ isOpen: !state.isOpen })),
40
  setMode: (mode) => set({ mode }),
@@ -42,14 +47,19 @@ export const useAIStore = create<AIState>((set, get) => ({
42
  addMessage: (msg) => set((state) => ({ messages: [...state.messages, msg] })),
43
  clearHistory: () => set({ messages: [] }),
44
 
 
 
 
 
 
45
  checkRagStatus: async () => {
46
  set({ ragChecking: true });
47
  try {
48
  const res = await fetch('/api/ask/rag-status');
49
  const data = await res.json();
50
- set({ ragReady: data.ready ?? false });
51
  } catch {
52
- set({ ragReady: false });
53
  } finally {
54
  set({ ragChecking: false });
55
  }
 
14
  useRag: boolean;
15
  ragReady: boolean; // true when Qwen + ChromaDB loaded
16
  ragChecking: boolean; // true while checking status
17
+ ragLoading: boolean; // true when ChromaDB exists but model still loading
18
+ dragPos: { x: number; y: number } | null; // saved drag position (desktop)
19
 
20
  // Actions
21
  toggleOpen: () => void;
 
23
  setUseRag: (use: boolean) => void;
24
  addMessage: (msg: Message) => void;
25
  clearHistory: () => void;
26
+ setDragPos: (pos: { x: number; y: number }) => void;
27
 
28
  // SSE Streaming logic
29
  askAI: (question: string, context: string) => Promise<void>;
 
38
  useRag: false,
39
  ragReady: false,
40
  ragChecking: false,
41
+ ragLoading: false,
42
+ dragPos: null,
43
 
44
  toggleOpen: () => set((state) => ({ isOpen: !state.isOpen })),
45
  setMode: (mode) => set({ mode }),
 
47
  addMessage: (msg) => set((state) => ({ messages: [...state.messages, msg] })),
48
  clearHistory: () => set({ messages: [] }),
49
 
50
+ setDragPos: (pos) => {
51
+ set({ dragPos: pos });
52
+ try { localStorage.setItem('tipitaka-ai-drag-pos', JSON.stringify(pos)); } catch {}
53
+ },
54
+
55
  checkRagStatus: async () => {
56
  set({ ragChecking: true });
57
  try {
58
  const res = await fetch('/api/ask/rag-status');
59
  const data = await res.json();
60
+ set({ ragReady: data.ready ?? false, ragLoading: data.loading ?? false });
61
  } catch {
62
+ set({ ragReady: false, ragLoading: false });
63
  } finally {
64
  set({ ragChecking: false });
65
  }