Spaces:
Sleeping
Sleeping
| 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 ( | |
| <div className="typing-animation"> | |
| <div className="typing-text"> | |
| AI 思考中{'.'.repeat(dots)} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| // 或者使用更生動的打字效果 | |
| const TypingIndicator = () => ( | |
| <div className="typing-indicator"> | |
| <span>AI 正在思考</span> | |
| <div className="typing-dots"> | |
| <span></span> | |
| <span></span> | |
| <span></span> | |
| </div> | |
| </div> | |
| ); | |
| 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 <div>Loading...</div> | |
| if (error) return <div>Error: {error}</div> | |
| if (!graphData) return <div>No data</div> | |
| return ( | |
| <Container maxWidth={false} disableGutters sx={{ | |
| height: 'calc(100vh - 32px)', // 減少高度,增加底部間距 | |
| backgroundColor: '#f5f5f5', | |
| overflow: 'hidden', | |
| my: 2 // 增加上下 margin | |
| }}> | |
| {/* 移除頂部區域,只保留一條分隔線 */} | |
| <Box sx={{ | |
| borderBottom: '1px solid rgba(0, 0, 0, 0.12)', | |
| backgroundColor: '#fff', | |
| height: '1px' | |
| }} /> | |
| {/* 主要內容區域 */} | |
| <Box sx={{ | |
| display: 'flex', | |
| height: 'calc(100% - 1px)', // 調整為相對高度 | |
| transition: 'all 0.3s ease' | |
| }}> | |
| {/* 左側圖譜面板 */} | |
| <Box sx={{ | |
| width: expandedPanels.leftPanel ? '33.33%' : '48px', | |
| transition: 'all 0.3s ease', | |
| borderRight: '1px solid rgba(0, 0, 0, 0.12)', | |
| backgroundColor: '#fff', | |
| position: 'relative', | |
| p: 2 | |
| }}> | |
| <IconButton | |
| onClick={() => 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 ? <ChevronLeftIcon /> : <ChevronRightIcon />} | |
| </IconButton> | |
| {/* 圖譜部分 */} | |
| <Paper elevation={2} sx={{ | |
| height: '100%', | |
| p: 2, | |
| borderRadius: 3, | |
| backgroundColor: '#fff', | |
| border: '1px solid rgba(0, 0, 0, 0.1)', | |
| boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)', | |
| display: 'flex', | |
| flexDirection: 'column' | |
| }}> | |
| <Box sx={{ | |
| display: 'flex', | |
| flexDirection: 'column', | |
| gap: 2, | |
| mb: 2 | |
| }}> | |
| <Typography variant="h6" sx={{ fontWeight: 600 }}> | |
| 知識圖譜學習系統 | |
| </Typography> | |
| <FormControl size="small" fullWidth> | |
| <InputLabel>選擇知識圖譜</InputLabel> | |
| <Select | |
| value={selectedGraphId} | |
| onChange={(e) => setSelectedGraphId(e.target.value)} | |
| label="選擇知識圖譜" | |
| sx={{ | |
| backgroundColor: '#fff', | |
| borderRadius: 2 | |
| }} | |
| > | |
| {availableGraphs.map(graph => ( | |
| <MenuItem key={graph.id} value={graph.id}> | |
| {graph.title} | |
| </MenuItem> | |
| ))} | |
| </Select> | |
| </FormControl> | |
| </Box> | |
| <Box sx={{ | |
| flexGrow: 1, | |
| display: expandedPanels.leftPanel ? 'block' : 'none', | |
| overflow: 'hidden' | |
| }}> | |
| <KnowledgeGraph | |
| nodes={graphData?.nodes} | |
| edges={graphData?.edges} | |
| onNodeClick={handleNodeClick} | |
| selectedNodeId={selectedNode?.id} | |
| graphId={selectedGraphId} | |
| /> | |
| </Box> | |
| </Paper> | |
| </Box> | |
| {/* 中間區域 */} | |
| <Box sx={{ | |
| width: `${(getMiddleWidth() / 12) * 100}%`, | |
| transition: 'all 0.3s ease', | |
| display: 'flex', | |
| flexDirection: 'column' | |
| }}> | |
| {/* 中間上方:內容 */} | |
| <Box sx={{ | |
| height: expandedPanels.content ? (expandedPanels.exercise ? '50%' : '50%') : '48px', | |
| transition: 'all 0.3s ease', | |
| borderBottom: '1px solid rgba(0, 0, 0, 0.12)', | |
| backgroundColor: '#fff' | |
| }}> | |
| <Paper elevation={2} sx={{ | |
| height: '100%', | |
| p: 2, | |
| borderRadius: 3, | |
| backgroundColor: '#fff', | |
| border: '1px solid rgba(0, 0, 0, 0.1)', | |
| boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)', | |
| display: 'flex', | |
| flexDirection: 'column' | |
| }}> | |
| <Box sx={{ | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'space-between', | |
| mb: expandedPanels.content ? 2 : 0 | |
| }}> | |
| <Typography variant="h6" sx={{ fontWeight: 600 }}> | |
| 內容 | |
| </Typography> | |
| <IconButton | |
| onClick={() => togglePanel('content')} | |
| size="small" | |
| sx={{ | |
| transition: 'transform 0.3s ease', | |
| transform: expandedPanels.content ? 'rotate(0deg)' : 'rotate(180deg)' | |
| }} | |
| > | |
| <ExpandLessIcon /> | |
| </IconButton> | |
| </Box> | |
| <Box sx={{ | |
| flexGrow: 1, | |
| display: expandedPanels.content ? 'block' : 'none', | |
| overflow: 'auto' | |
| }}> | |
| {selectedNode ? ( | |
| <> | |
| <Typography variant="h6" sx={{ | |
| mb: 2, | |
| fontWeight: 600, | |
| color: theme.palette.primary.main | |
| }}> | |
| {selectedNode.title} | |
| </Typography> | |
| <div dangerouslySetInnerHTML={{ __html: selectedNode.content }} /> | |
| {selectedNode.resources && ( | |
| <Box sx={{ mt: 3 }}> | |
| <Typography variant="h6" sx={{ mb: 1, fontWeight: 600 }}> | |
| 延伸資源 | |
| </Typography> | |
| <Box component="ul" sx={{ | |
| listStyle: 'none', | |
| p: 0, | |
| m: 0 | |
| }}> | |
| {selectedNode.resources.map((resource, index) => ( | |
| <Box | |
| component="li" | |
| key={index} | |
| sx={{ | |
| mb: 1, | |
| '& a': { | |
| color: theme.palette.primary.main, | |
| textDecoration: 'none', | |
| '&:hover': { | |
| textDecoration: 'underline' | |
| } | |
| } | |
| }} | |
| > | |
| <a href={resource.url} target="_blank" rel="noopener noreferrer"> | |
| {resource.type}: {resource.url} | |
| </a> | |
| </Box> | |
| ))} | |
| </Box> | |
| </Box> | |
| )} | |
| </> | |
| ) : ( | |
| <Typography variant="body1" sx={{ color: 'text.secondary' }}> | |
| 請選擇一個節點查看詳細內容 | |
| </Typography> | |
| )} | |
| </Box> | |
| </Paper> | |
| </Box> | |
| {/* 中間下方:練習 */} | |
| <Box sx={{ | |
| height: expandedPanels.exercise ? (expandedPanels.content ? '50%' : 'calc(100% - 48px)') : '48px', | |
| transition: 'all 0.3s ease', | |
| backgroundColor: '#fff', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| position: 'relative', | |
| overflow: 'hidden' | |
| }}> | |
| <Paper elevation={2} sx={{ | |
| height: '100%', | |
| p: 2, | |
| borderRadius: 3, | |
| backgroundColor: '#fff', | |
| border: '1px solid rgba(0, 0, 0, 0.1)', | |
| boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| position: 'relative' | |
| }}> | |
| <Box sx={{ | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'space-between', | |
| mb: expandedPanels.exercise ? 2 : 0, | |
| position: 'sticky', | |
| top: 0, | |
| backgroundColor: '#fff', | |
| zIndex: 2 | |
| }}> | |
| <Typography variant="h6" sx={{ fontWeight: 600 }}> | |
| 練習 {nodeExercises.length > 0 && `(${nodeExercises.length}題)`} | |
| </Typography> | |
| <IconButton | |
| onClick={() => togglePanel('exercise')} | |
| size="small" | |
| sx={{ | |
| transition: 'transform 0.3s ease', | |
| transform: expandedPanels.exercise ? 'rotate(0deg)' : 'rotate(180deg)' | |
| }} | |
| > | |
| <ExpandMoreIcon /> | |
| </IconButton> | |
| </Box> | |
| <Box sx={{ | |
| position: 'absolute', | |
| top: '48px', | |
| left: 0, | |
| right: 0, | |
| bottom: 0, | |
| overflow: 'auto', | |
| transition: 'all 0.3s ease', | |
| transform: expandedPanels.exercise ? 'translateY(0)' : 'translateY(-100%)', | |
| opacity: expandedPanels.exercise ? 1 : 0, | |
| visibility: expandedPanels.exercise ? 'visible' : 'hidden', | |
| p: 2 | |
| }}> | |
| {nodeExercises.length > 0 ? ( | |
| <Collapse in={expandedPanels.exercise}> | |
| <Box sx={{ mt: 2 }}> | |
| <Typography variant="h6" sx={{ mb: 1 }}>練習題</Typography> | |
| <Exercise | |
| key={nodeExercises[currentExerciseIndex].id} | |
| exercise={nodeExercises[currentExerciseIndex]} | |
| onSubmit={handleExerciseSubmit} | |
| onNextExercise={currentExerciseIndex < nodeExercises.length - 1 ? handleNextExercise : null} | |
| result={result} | |
| selectedAnswer={selectedAnswer?.id || selectedAnswer} | |
| onAnswerSelect={handleAnswerSelect} | |
| /> | |
| </Box> | |
| </Collapse> | |
| ) : ( | |
| <Typography variant="body1" sx={{ color: 'text.secondary' }}> | |
| 請選擇一個節點查看相關練習 | |
| </Typography> | |
| )} | |
| </Box> | |
| </Paper> | |
| </Box> | |
| </Box> | |
| {/* 右側對話面板 */} | |
| <Box sx={{ | |
| width: expandedPanels.rightPanel ? '33.33%' : '48px', | |
| transition: 'all 0.3s ease', | |
| borderLeft: '1px solid rgba(0, 0, 0, 0.12)', | |
| backgroundColor: '#fff', | |
| position: 'relative', | |
| p: 2, | |
| pb: { xs: 3, sm: 4, md: 5 } // 根據螢幕大小調整底部間距 | |
| }}> | |
| <IconButton | |
| onClick={() => 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 ? <ChevronRightIcon /> : <ChevronLeftIcon />} | |
| </IconButton> | |
| {/* 對話面板 */} | |
| <Paper elevation={2} sx={{ | |
| height: { | |
| xs: 'calc(100% - 24px)', // 小螢幕 | |
| sm: 'calc(100% - 32px)', // 中螢幕 | |
| md: 'calc(100% - 40px)' // 大螢幕 | |
| }, | |
| p: 2, | |
| borderRadius: 3, | |
| backgroundColor: '#fff', | |
| border: '1px solid rgba(0, 0, 0, 0.1)', | |
| boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| position: 'relative' | |
| }}> | |
| <Box sx={{ | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'space-between', | |
| mb: 2 | |
| }}> | |
| <Typography variant="h6" sx={{ fontWeight: 600 }}> | |
| AI 對話 | |
| </Typography> | |
| </Box> | |
| {/* 聊天內容區域 */} | |
| <Box | |
| ref={chatContainerRef} | |
| sx={{ | |
| flexGrow: 1, | |
| overflow: 'auto', | |
| mb: 2, | |
| pr: 1, | |
| scrollBehavior: 'smooth' | |
| }} | |
| > | |
| {chatHistory.map((chat, index) => ( | |
| <Box key={index} sx={{ mb: 3 }}> | |
| {/* 用戶訊息 */} | |
| <Box sx={{ | |
| display: 'flex', | |
| justifyContent: 'flex-end', | |
| mb: 1 | |
| }}> | |
| <Box sx={{ | |
| maxWidth: '80%', | |
| p: 2, | |
| backgroundColor: theme.palette.primary.main, | |
| color: '#fff', | |
| borderRadius: '20px 20px 0 20px', | |
| wordBreak: 'break-word', | |
| whiteSpace: 'pre-wrap' | |
| }}> | |
| {chat.question} | |
| </Box> | |
| </Box> | |
| {/* AI 回應 - 使用 ReactMarkdown */} | |
| <Box sx={{ | |
| display: 'flex', | |
| mb: 1 | |
| }}> | |
| <Box | |
| className={chat.loading ? "ai-bubble breathing" : "ai-bubble"} | |
| sx={{ | |
| maxWidth: '80%', | |
| p: 2, | |
| backgroundColor: '#f0f0f0', | |
| borderRadius: '20px 20px 20px 0', | |
| wordBreak: 'break-word', | |
| position: 'relative', | |
| transition: 'all 0.3s ease', | |
| overflow: 'hidden', | |
| '& code': { | |
| display: 'block', | |
| padding: '8px', | |
| backgroundColor: '#1e1e1e', | |
| color: '#fff', | |
| borderRadius: '4px', | |
| overflowX: 'auto', | |
| fontFamily: 'monospace' | |
| }, | |
| '& pre': { | |
| margin: '8px 0', | |
| padding: '12px', | |
| backgroundColor: '#f5f5f5', | |
| borderRadius: '4px', | |
| overflowX: 'auto' | |
| }, | |
| '& ul, & ol': { | |
| paddingLeft: '20px', | |
| margin: '8px 0' | |
| }, | |
| '& p': { | |
| margin: '8px 0' | |
| }, | |
| '& a': { | |
| color: '#0077cc', | |
| textDecoration: 'underline' | |
| }, | |
| '& blockquote': { | |
| borderLeft: '4px solid #ddd', | |
| paddingLeft: '16px', | |
| margin: '8px 0', | |
| color: '#555' | |
| }, | |
| '& table': { | |
| borderCollapse: 'collapse', | |
| width: '100%', | |
| margin: '12px 0' | |
| }, | |
| '& th, & td': { | |
| border: '1px solid #ddd', | |
| padding: '8px', | |
| textAlign: 'left' | |
| }, | |
| '& th': { | |
| backgroundColor: '#f2f2f2' | |
| } | |
| }} | |
| > | |
| <div className="message-content"> | |
| {chat.answer ? ( | |
| <ReactMarkdown | |
| remarkPlugins={[remarkGfm]} | |
| rehypePlugins={[rehypeRaw]} | |
| components={{ | |
| // 自定義代碼塊渲染 | |
| code({node, inline, className, children, ...props}) { | |
| const match = /language-(\w+)/.exec(className || ''); | |
| return !inline && match ? ( | |
| <pre className={`language-${match[1]}`}> | |
| <code className={`language-${match[1]}`} {...props}> | |
| {children} | |
| </code> | |
| </pre> | |
| ) : ( | |
| <code className={className} {...props}> | |
| {children} | |
| </code> | |
| ); | |
| } | |
| }} | |
| > | |
| {chat.answer} | |
| </ReactMarkdown> | |
| ) : ( | |
| chat.loading && !chat.answer && "" | |
| )} | |
| </div> | |
| {chat.loading && <TypingAnimation />} | |
| </Box> | |
| </Box> | |
| </Box> | |
| ))} | |
| </Box> | |
| {/* 輸入區域 */} | |
| <Box sx={{ | |
| pt: 2, | |
| borderTop: '1px solid rgba(0, 0, 0, 0.12)', | |
| backgroundColor: '#fff', | |
| position: 'sticky', | |
| bottom: 0, | |
| zIndex: 1, | |
| mt: 'auto', | |
| mb: { xs: 1, sm: 1.5, md: 2 } // 根據螢幕大小調整輸入框底部間距 | |
| }}> | |
| <Box sx={{ display: 'flex', gap: 1 }}> | |
| <TextField | |
| inputRef={inputRef} | |
| fullWidth | |
| value={chatMessage} | |
| onChange={(e) => 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 | |
| } | |
| }} | |
| /> | |
| <IconButton | |
| onClick={handleSendMessage} | |
| disabled={chatLoading || !chatMessage.trim()} | |
| sx={{ | |
| backgroundColor: theme.palette.primary.main, | |
| color: '#fff', | |
| '&:hover': { | |
| backgroundColor: theme.palette.primary.dark | |
| }, | |
| '&.Mui-disabled': { | |
| backgroundColor: '#e0e0e0', | |
| color: '#9e9e9e' | |
| } | |
| }} | |
| > | |
| <SendIcon /> | |
| </IconButton> | |
| </Box> | |
| </Box> | |
| </Paper> | |
| </Box> | |
| </Box> | |
| </Container> | |
| ) | |
| } | |
| export default App |