tai-JY / src /App.jsx
youngtsai's picture
graphDataCache
7f8f87b
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