import React, { useEffect, useState, useRef, useCallback } from 'react' import { Container, Grid, Paper, Box, Select, MenuItem, FormControl, InputLabel, TextField, Button, Typography, IconButton, useTheme, Divider, Collapse } from '@mui/material' import SendIcon from '@mui/icons-material/Send' import axios from 'axios' import ReactMarkdown from 'react-markdown' import rehypeRaw from 'rehype-raw' import remarkGfm from 'remark-gfm' import KnowledgeGraph from './components/KnowledgeGraph/KnowledgeGraph' import NodeContent from './components/NodeContent/NodeContent' import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import Exercise from './components/Exercise/Exercise'; // 1. 添加一個全局變量來保存圖譜數據 const globalGraphDataCache = { current: null }; // 改進的打字動畫組件 - 使用點數循環 const TypingAnimation = () => { const [dots, setDots] = useState(1); useEffect(() => { const interval = setInterval(() => { setDots(prev => prev < 3 ? prev + 1 : 1); }, 500); // 每500毫秒變化一次 return () => clearInterval(interval); }, []); return (
AI 思考中{'.'.repeat(dots)}
); }; // 或者使用更生動的打字效果 const TypingIndicator = () => (
AI 正在思考
); function App() { const [availableGraphs, setAvailableGraphs] = useState([]) const [selectedGraphId, setSelectedGraphId] = useState('') const [graphData, setGraphData] = useState(null) const [selectedNode, setSelectedNode] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [chatMessage, setChatMessage] = useState('') const [chatHistory, setChatHistory] = useState([]) const [chatLoading, setChatLoading] = useState(false) const [currentStreamingResponse, setCurrentStreamingResponse] = useState('') const [threadId, setThreadId] = useState(null) const chatContainerRef = useRef(null) const inputRef = useRef(null) const theme = useTheme(); const [expandedPanels, setExpandedPanels] = useState({ leftPanel: true, // 左側圖譜面板 rightPanel: true, // 右側練習面板 content: true, // 中間上方內容 exercise: true // 中間下方練習 }); const [currentExerciseIndex, setCurrentExerciseIndex] = useState(0); const [nodeExercises, setNodeExercises] = useState([]); const [exerciseHistory, setExerciseHistory] = useState([]); const [result, setResult] = useState(null); // 添加 result 狀態 const [selectedAnswer, setSelectedAnswer] = useState(null); const [lastNodeClickTime, setLastNodeClickTime] = useState(0); const [currentContentStatus, setCurrentContentStatus] = useState({}); const scrollToBottom = useCallback(() => { if (chatContainerRef.current) { const element = chatContainerRef.current; const scrollHeight = element.scrollHeight; const height = element.clientHeight; const maxScrollTop = scrollHeight - height; // 使用平滑滾動 element.scrollTo({ top: maxScrollTop, behavior: 'smooth' }); } }, []); useEffect(() => { scrollToBottom(); }, [chatHistory, scrollToBottom]); useEffect(() => { axios.get('/api/graphs') .then(response => { const graphs = response.data.available_graphs setAvailableGraphs(graphs) // 找出 fraud_prevention 圖譜 const fraudPreventionGraph = graphs.find(graph => graph.id === 'fraud_prevention'); // 如果找到 fraud_prevention,則設為預設選擇 // 否則使用第一個圖譜 if (fraudPreventionGraph) { setSelectedGraphId(fraudPreventionGraph.id); } else if (graphs.length > 0) { setSelectedGraphId(graphs[0].id); } }) .catch(error => { console.error('Error fetching graphs:', error) setError('Error fetching available graphs') }) }, []) // 2. 修改 useEffect,在圖譜數據加載後保存到全局變量 useEffect(() => { if (!selectedGraphId) return; const loadGraphData = async () => { setLoading(true); try { const response = await axios.get(`/api/graph/${selectedGraphId}`); const newGraphData = response.data.graph; setGraphData(newGraphData); // 保存到全局變量 globalGraphDataCache.current = newGraphData; // 同時保存到 localStorage,作為額外的備份 try { localStorage.setItem('graphData', JSON.stringify(newGraphData)); localStorage.setItem('selectedGraphId', selectedGraphId); } catch (storageError) { console.error('Error saving to localStorage:', storageError); } setSelectedNode(null); } catch (error) { console.error('Error loading graph:', error); setError('Error loading graph'); // 嘗試從 localStorage 恢復 try { const cachedData = localStorage.getItem('graphData'); const cachedGraphId = localStorage.getItem('selectedGraphId'); if (cachedData && cachedGraphId === selectedGraphId) { const parsedData = JSON.parse(cachedData); setGraphData(parsedData); globalGraphDataCache.current = parsedData; console.log('Restored graph data from localStorage'); } } catch (storageError) { console.error('Error restoring from localStorage:', storageError); } } finally { setLoading(false); } }; loadGraphData(); }, [selectedGraphId]); // 3. 添加一個監視器,當圖譜數據為 null 但全局變量存在時恢復數據 useEffect(() => { if (!graphData && globalGraphDataCache.current) { console.log('圖譜數據丟失,從全局變量恢復...'); setGraphData(globalGraphDataCache.current); } }, [graphData]); // 在組件加載時創建新的對話串 useEffect(() => { fetch('/api/chat/thread', { method: 'POST' }) .then(response => response.json()) .then(data => setThreadId(data.thread_id)) .catch(error => console.error('Error creating thread:', error)) }, []) const fetchNodeExercises = useCallback(async (nodeId) => { if (!nodeId) return; try { console.log(`Fetching exercises for node: ${nodeId}`); // 添加日誌 const response = await axios.get(`/api/exercises/${nodeId}`); console.log(`Fetched exercises:`, response.data); // 添加日誌 setNodeExercises(response.data.exercises || []); // 更新練習題狀態,確保是陣列 setCurrentExerciseIndex(0); // 重置練習題索引 setResult(null); // 清除之前的結果 setSelectedAnswer(''); // 清除之前的選擇 } catch (error) { console.error('Error fetching node exercises:', error); setError('Error fetching exercises for the node'); setNodeExercises([]); // 出錯時清空練習題 } }, []); const handleNodeClick = useCallback(async (nodeId) => { console.log(`Node clicked: ${nodeId}, Graph ID: ${selectedGraphId}`); if (!selectedGraphId) { console.error("No graph selected when clicking node."); setError("Please select a graph first."); return; } try { const response = await axios.get(`/api/graph/${selectedGraphId}/node/${nodeId}`); console.log("Fetched node data:", response.data); setSelectedNode(response.data); // 重置練習題相關狀態 setNodeExercises([]); // 先清空 setCurrentExerciseIndex(0); setResult(null); setSelectedAnswer(''); // 獲取與新節點相關的練習題 fetchNodeExercises(nodeId); } catch (error) { console.error('Error fetching node details:', error); setError('Error fetching node details'); setSelectedNode(null); } }, [selectedGraphId, fetchNodeExercises]); // 每次圖譜、節點、練習題變動時更新 currentContentStatus useEffect(() => { // 獲取當前練習題 const currentExercise = nodeExercises.length > 0 ? nodeExercises[currentExerciseIndex] : null; // 更新 currentContentStatus,包含當前練習題 setCurrentContentStatus({ graph: graphData, node: selectedNode, exercises: nodeExercises, currentExercise: currentExercise, exerciseResult: result, // 添加練習題結果 selectedAnswer: selectedAnswer // 添加用戶選擇的答案 }); }, [graphData, selectedNode, nodeExercises, currentExerciseIndex, result, selectedAnswer]); // 4. 修改 handleSendMessage 函數,確保在聊天過程中保持圖譜數據 const handleSendMessage = async () => { if (!chatMessage.trim() || chatLoading) return; setChatLoading(true); setCurrentStreamingResponse(''); // 保存當前圖譜數據的備份 const currentGraphDataBackup = graphData || globalGraphDataCache.current; // 先將用戶問題添加到聊天歷史 const userMessage = chatMessage; setChatHistory(prev => [...prev, { question: userMessage, answer: '', loading: true }]); setChatMessage(''); try { // 在發送請求前確保圖譜數據不會丟失 if (!graphData && currentGraphDataBackup) { console.log('在發送請求前恢復圖譜數據...'); setGraphData(currentGraphDataBackup); } // 使用 fetch 代替 axios 處理 streaming response const response = await fetch(`/api/chat/${threadId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: userMessage, graph_id: selectedGraphId, node_id: selectedNode?.id, current_content_status: currentContentStatus }) }); if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); } if (!response.body) { throw new Error('No response body'); } // 使用 ReadableStream API 處理流式回應 const reader = response.body.getReader(); let accumulatedResponse = ''; // 更新最後一條聊天記錄的 answer const updateLastChatAnswer = (text, isLoading = true) => { setChatHistory(prev => { const newHistory = [...prev]; if (newHistory.length > 0) { newHistory[newHistory.length - 1] = { ...newHistory[newHistory.length - 1], answer: text, loading: isLoading }; } return newHistory; }); // 確保圖譜數據不會丟失 if (!graphData && currentGraphDataBackup) { console.log('在聊天過程中恢復圖譜數據...'); setGraphData(currentGraphDataBackup); } }; while (true) { const { done, value } = await reader.read(); if (done) { // 完成時,設置 loading 為 false updateLastChatAnswer(accumulatedResponse, false); break; } // 解碼收到的數據 const chunk = new TextDecoder().decode(value); accumulatedResponse += chunk; // 更新 UI 顯示,保持 loading 狀態 updateLastChatAnswer(accumulatedResponse, true); // 每次更新後檢查圖譜數據 if (!graphData && currentGraphDataBackup) { console.log('在聊天流式響應中恢復圖譜數據...'); setGraphData(currentGraphDataBackup); } } } catch (error) { console.error('Error sending message:', error); setError(`發送訊息時出錯: ${error.message}`); // 更新最後一條聊天記錄,顯示錯誤 setChatHistory(prev => { const newHistory = [...prev]; if (newHistory.length > 0) { newHistory[newHistory.length - 1] = { ...newHistory[newHistory.length - 1], answer: `錯誤: ${error.message}`, loading: false }; } return newHistory; }); // 確保圖譜數據不會丟失 if (!graphData && currentGraphDataBackup) { console.log('在錯誤處理中恢復圖譜數據...'); setGraphData(currentGraphDataBackup); } } finally { setChatLoading(false); // 最後再次確保圖譜數據不會丟失 if (!graphData && currentGraphDataBackup) { console.log('在聊天完成後恢復圖譜數據...'); setGraphData(currentGraphDataBackup); } // 延遲檢查,以防異步更新後圖譜消失 setTimeout(() => { if (!graphData && globalGraphDataCache.current) { console.log('延遲檢查:恢復圖譜數據...'); setGraphData(globalGraphDataCache.current); } }, 500); } }; const togglePanel = (panel) => { setExpandedPanels(prev => ({ ...prev, [panel]: !prev[panel] })); }; // 計算中間區域的寬度 const getMiddleWidth = () => { const leftExpanded = expandedPanels.leftPanel ? 4 : 0; const rightExpanded = expandedPanels.rightPanel ? 4 : 0; return 12 - leftExpanded - rightExpanded; }; const handleAnswerSelect = (answerId, answerText) => { // 保存答案 ID 和文本 setSelectedAnswer({ id: answerId, text: answerText }); // 更新 currentContentStatus setCurrentContentStatus(prev => ({ ...prev, selectedAnswer: { id: answerId, text: answerText } })); }; const handleExerciseSubmit = async (answerId, answerText) => { try { // 先更新選擇的答案 const answer = { id: answerId, text: answerText }; setSelectedAnswer(answer); // 更新 currentContentStatus setCurrentContentStatus(prev => ({ ...prev, selectedAnswer: answer })); // 發送請求 const response = await axios.post(`/api/exercises/check/${nodeExercises[currentExerciseIndex].id}`, { answer: answerId, show_explanation: true }); setResult(response.data); // 更新 currentContentStatus 以包含結果 setCurrentContentStatus(prev => ({ ...prev, exerciseResult: response.data, selectedAnswer: answer })); // 更新練習歷史記錄 setExerciseHistory(prev => [...prev, { exerciseId: nodeExercises[currentExerciseIndex].id, answer: answerId, answerText: answerText, correct: response.data.correct }]); } catch (error) { console.error('Error checking exercise answer:', error); setResult({ correct: false, explanation: "檢查答案時發生錯誤" }); } }; const handleNextExercise = useCallback(() => { if (currentExerciseIndex < nodeExercises.length - 1) { setCurrentExerciseIndex(prevIndex => prevIndex + 1); setResult(null); // 清除結果 setSelectedAnswer(''); // 清除選擇 } else { // 可以添加一個提示,表示沒有更多練習題了 console.log("No more exercises for this node."); } }, [currentExerciseIndex, nodeExercises.length]); if (loading) return
Loading...
if (error) return
Error: {error}
if (!graphData) return
No data
return ( {/* 移除頂部區域,只保留一條分隔線 */} {/* 主要內容區域 */} {/* 左側圖譜面板 */} togglePanel('leftPanel')} sx={{ position: 'absolute', right: '-12px', top: '50%', transform: 'translateY(-50%)', zIndex: 1, backgroundColor: '#fff', boxShadow: '0 2px 4px rgba(0,0,0,0.1)', '&:hover': { backgroundColor: '#f5f5f5' } }} > {expandedPanels.leftPanel ? : } {/* 圖譜部分 */} 知識圖譜學習系統 選擇知識圖譜 {/* 中間區域 */} {/* 中間上方:內容 */} 內容 togglePanel('content')} size="small" sx={{ transition: 'transform 0.3s ease', transform: expandedPanels.content ? 'rotate(0deg)' : 'rotate(180deg)' }} > {selectedNode ? ( <> {selectedNode.title}
{selectedNode.resources && ( 延伸資源 {selectedNode.resources.map((resource, index) => ( {resource.type}: {resource.url} ))} )} ) : ( 請選擇一個節點查看詳細內容 )} {/* 中間下方:練習 */} 練習 {nodeExercises.length > 0 && `(${nodeExercises.length}題)`} togglePanel('exercise')} size="small" sx={{ transition: 'transform 0.3s ease', transform: expandedPanels.exercise ? 'rotate(0deg)' : 'rotate(180deg)' }} > {nodeExercises.length > 0 ? ( 練習題 ) : ( 請選擇一個節點查看相關練習 )} {/* 右側對話面板 */} togglePanel('rightPanel')} sx={{ position: 'absolute', left: '-12px', top: '50%', transform: 'translateY(-50%)', zIndex: 1, backgroundColor: '#fff', boxShadow: '0 2px 4px rgba(0,0,0,0.1)', '&:hover': { backgroundColor: '#f5f5f5' } }} > {expandedPanels.rightPanel ? : } {/* 對話面板 */} AI 對話 {/* 聊天內容區域 */} {chatHistory.map((chat, index) => ( {/* 用戶訊息 */} {chat.question} {/* AI 回應 - 使用 ReactMarkdown */}
{chat.answer ? ( {children} ) : ( {children} ); } }} > {chat.answer} ) : ( chat.loading && !chat.answer && "" )}
{chat.loading && }
))}
{/* 輸入區域 */} setChatMessage(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey && !chatLoading) { e.preventDefault(); if (chatMessage.trim()) { handleSendMessage(); } } }} placeholder="輸入您的問題..." disabled={chatLoading} multiline maxRows={4} sx={{ '& .MuiOutlinedInput-root': { borderRadius: 3 } }} />
) } export default App