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 (
);
};
// 或者使用更生動的打字效果
const TypingIndicator = () => (
);
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