Spaces:
Running
Running
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(
|
| 21 |
-
"""
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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={{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
| 55 |
-
// Mobile: centered
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
>
|
| 64 |
-
{/* ═══════════ Compact Header +
|
| 65 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 111 |
-
<div className=
|
| 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 |
-
{
|
| 119 |
-
|
| 120 |
-
|
| 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 |
-
|
| 133 |
-
|
| 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
|
| 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 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
onClick={() => { const c = `เล่มที่ ${currentVolume} หน้าที่ ${currentPage}\nเนื้อหา:\n${currentContent}`; askAI(
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 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 |
-
|
| 216 |
-
|
| 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 |
}
|