Spaces:
Running
Running
import React, { useState, useEffect, useRef } from 'react'; | |
import { | |
Box, | |
Paper, | |
Grid, | |
Typography, | |
Button, | |
CircularProgress, | |
Tabs, | |
Tab, | |
Divider, | |
Card, | |
CardContent, | |
CardMedia, | |
IconButton, | |
TextField | |
} from '@material-ui/core'; | |
import { AddCircle, Delete, Compare, Search, Info } from '@material-ui/icons'; | |
import { makeStyles } from '@material-ui/core/styles'; | |
const useStyles = makeStyles((theme) => ({ | |
root: { | |
marginTop: theme.spacing(3), | |
marginBottom: theme.spacing(3), | |
}, | |
imageContainer: { | |
position: 'relative', | |
minHeight: '360px', | |
display: 'flex', | |
justifyContent: 'center', | |
alignItems: 'center', | |
border: '2px dashed #ccc', | |
borderRadius: '8px', | |
margin: theme.spacing(1), | |
padding: theme.spacing(1), | |
backgroundColor: '#fafafa', | |
overflow: 'hidden', | |
}, | |
progressLog: { | |
marginTop: theme.spacing(2), | |
height: '200px', | |
overflowY: 'auto', | |
backgroundColor: '#f8f9fa', | |
padding: theme.spacing(1), | |
borderRadius: '4px', | |
fontFamily: 'monospace', | |
fontSize: '0.9rem', | |
}, | |
logEntry: { | |
margin: '4px 0', | |
padding: '2px 5px', | |
borderLeft: '3px solid #ccc', | |
}, | |
logEntryAgent: { | |
borderLeft: '3px solid #2196f3', | |
}, | |
logEntrySystem: { | |
borderLeft: '3px solid #4caf50', | |
}, | |
logEntryError: { | |
borderLeft: '3px solid #f44336', | |
}, | |
logTime: { | |
color: '#666', | |
fontSize: '0.8rem', | |
marginRight: theme.spacing(1), | |
}, | |
imagePreview: { | |
width: '100%', | |
height: 'auto', | |
maxHeight: '60vh', | |
objectFit: 'contain', | |
}, | |
uploadIcon: { | |
fontSize: '3rem', | |
color: '#aaa', | |
}, | |
uploadInput: { | |
display: 'none', | |
}, | |
deleteButton: { | |
position: 'absolute', | |
top: '8px', | |
right: '8px', | |
backgroundColor: 'rgba(255, 255, 255, 0.8)', | |
'&:hover': { | |
backgroundColor: 'rgba(255, 255, 255, 0.9)', | |
}, | |
}, | |
tabPanel: { | |
padding: theme.spacing(2), | |
}, | |
resultCard: { | |
marginTop: theme.spacing(2), | |
marginBottom: theme.spacing(2), | |
}, | |
comparisonTable: { | |
width: '100%', | |
borderCollapse: 'collapse', | |
'& th, & td': { | |
border: '1px solid #ddd', | |
padding: '8px', | |
textAlign: 'left', | |
}, | |
'& th': { | |
backgroundColor: '#f2f2f2', | |
}, | |
'& tr:nth-child(even)': { | |
backgroundColor: '#f9f9f9', | |
}, | |
}, | |
loadingContainer: { | |
display: 'flex', | |
flexDirection: 'column', | |
alignItems: 'center', | |
padding: theme.spacing(4), | |
}, | |
highlight: { | |
backgroundColor: '#e3f2fd', | |
padding: theme.spacing(1), | |
borderRadius: '4px', | |
fontWeight: 'bold', | |
} | |
})); | |
// λΆμ μ ν ν ν¨λ μ»΄ν¬λνΈ | |
function TabPanel(props) { | |
const { children, value, index, ...other } = props; | |
return ( | |
<div | |
role="tabpanel" | |
hidden={value !== index} | |
id={`analysis-tabpanel-${index}`} | |
aria-labelledby={`analysis-tab-${index}`} | |
{...other} | |
> | |
{value === index && ( | |
<Box p={3}> | |
{children} | |
</Box> | |
)} | |
</div> | |
); | |
} | |
const ProductComparison = () => { | |
const classes = useStyles(); | |
const [images, setImages] = useState([null, null]); // μ΅λ 2κ° μ΄λ―Έμ§ μ μ₯ | |
const [imagePreviews, setImagePreviews] = useState([null, null]); | |
const [isProcessing, setIsProcessing] = useState(false); | |
const [analysisResults, setAnalysisResults] = useState(null); | |
const [error, setError] = useState(null); | |
const [tabValue, setTabValue] = useState(0); | |
const [progressLogs, setProgressLogs] = useState([]); | |
const logEndRef = useRef(null); | |
// ν λ³κ²½ νΈλ€λ¬ | |
const handleTabChange = (event, newValue) => { | |
setTabValue(newValue); | |
}; | |
// μ΄λ―Έμ§ μ λ‘λ νΈλ€λ¬ | |
const handleImageUpload = (event, index) => { | |
const file = event.target.files[0]; | |
if (file) { | |
// μ΄λ―Έμ§ 미리보기 μμ± | |
const reader = new FileReader(); | |
reader.onload = (e) => { | |
const newPreviews = [...imagePreviews]; | |
newPreviews[index] = e.target.result; | |
setImagePreviews(newPreviews); | |
}; | |
reader.readAsDataURL(file); | |
// μ΄λ―Έμ§ νμΌ μν μ λ°μ΄νΈ | |
const newImages = [...images]; | |
newImages[index] = file; | |
setImages(newImages); | |
// κ²°κ³Ό λ° μ€λ₯ μ΄κΈ°ν | |
setAnalysisResults(null); | |
setError(null); | |
} | |
}; | |
// μ΄λ―Έμ§ μμ νΈλ€λ¬ | |
const handleImageDelete = (index) => { | |
const newImages = [...images]; | |
const newPreviews = [...imagePreviews]; | |
newImages[index] = null; | |
newPreviews[index] = null; | |
setImages(newImages); | |
setImagePreviews(newPreviews); | |
setAnalysisResults(null); | |
}; | |
// λ‘κ·Έ μΆκ° ν¨μ | |
const addLog = (message, type = 'info') => { | |
const now = new Date(); | |
const timeStr = now.toLocaleTimeString(); | |
const newLog = { | |
time: timeStr, | |
message, | |
type // 'info', 'agent', 'system', 'error' | |
}; | |
setProgressLogs(logs => [...logs, newLog]); | |
}; | |
// λ‘κ·Έμ°½ μλ μ€ν¬λ‘€ | |
useEffect(() => { | |
if (logEndRef.current) { | |
logEndRef.current.scrollIntoView({ behavior: 'smooth' }); | |
} | |
}, [progressLogs]); | |
// SSE μ°κ²° ν¨μ | |
const connectToSSE = (sessionId) => { | |
const eventSource = new EventSource(`/api/product/compare/stream/${sessionId}`); | |
eventSource.onmessage = (event) => { | |
try { | |
const data = JSON.parse(event.data); | |
if (data.message) { | |
addLog(data.message, data.agent || 'info'); | |
} | |
// μ΅μ’ κ²°κ³Όκ° μ€λ©΄ λΆμ κ²°κ³Ό μ λ°μ΄νΈ | |
if (data.final_result) { | |
setAnalysisResults(data.final_result); | |
setIsProcessing(false); | |
eventSource.close(); | |
} | |
} catch (err) { | |
addLog(`Event processing error: ${err.message}`, 'error'); | |
} | |
}; | |
eventSource.onerror = (err) => { | |
addLog('Server connection lost. Please try again in a moment.', 'error'); | |
eventSource.close(); | |
setIsProcessing(false); | |
}; | |
return eventSource; | |
}; | |
// μ ν λΆμ μ²λ¦¬ νΈλ€λ¬ (analysisType κ°μ κ°λ₯) | |
const handleAnalysis = async (analysisOverride = null) => { | |
// μ ν¨μ± κ²μ¬: μ΅μ 1κ° μ΄μμ μ΄λ―Έμ§κ° μμ΄μΌ ν¨ | |
if (!images[0] && !images[1]) { | |
setError('Please upload at least one product image for analysis'); | |
return; | |
} | |
setIsProcessing(true); | |
setError(null); | |
setProgressLogs([]); // λ‘κ·Έ μ΄κΈ°ν | |
addLog('Starting product analysis...', 'system'); | |
try { | |
const formData = new FormData(); | |
// μ λ‘λλ μ΄λ―Έμ§λ§ FormDataμ μΆκ° | |
if (images[0]) { | |
formData.append('image1', images[0]); | |
addLog('Product A image uploaded.', 'info'); | |
} | |
if (images[1]) { | |
formData.append('image2', images[1]); | |
addLog('Product B image uploaded.', 'info'); | |
} | |
// λΆμ νμ μΆκ° (ν μΈλ±μ€λ‘ ꡬλΆ) νΉμ λͺ μμ override | |
const analysisTypes = ['info', 'compare', 'value', 'recommend']; | |
const analysisType = analysisOverride || analysisTypes[tabValue]; | |
formData.append('analysisType', analysisType); | |
addLog(`Analysis type: ${analysisType === 'info' ? 'Product Information' : analysisType === 'compare' ? 'Performance Comparison' : analysisType === 'value' ? 'Value Analysis' : 'Purchase Recommendation'}`, 'system'); | |
// λ°±μλ API νΈμΆ (μΈμ μμ) | |
addLog('Initializing analysis session...', 'system'); | |
// Debug FormData contents | |
for (let [key, value] of formData.entries()) { | |
console.log('FormData:', key, value); | |
} | |
const response = await fetch('/api/product/compare/start', { | |
method: 'POST', | |
body: formData, | |
credentials: 'include', // μΈμ μΏ ν€ ν¬ν¨ | |
}); | |
console.log('Response status:', response.status); | |
console.log('Response headers:', response.headers); | |
if (!response.ok) { | |
const errorText = await response.text(); | |
console.error('Error response:', errorText); | |
throw new Error(`HTTP error! Status: ${response.status} - ${errorText}`); | |
} | |
const data = await response.json(); | |
const sessionId = data.session_id; | |
if (!sessionId) { | |
throw new Error('Failed to receive session ID'); | |
} | |
addLog(`Analysis session started. (Session ID: ${sessionId.substring(0, 8)}...)`, 'system'); | |
addLog('Agents are collaborating to analyze products. Please wait a moment...', 'system'); | |
// SSE μ€νΈλ¦Ό μ°κ²° | |
const eventSource = connectToSSE(sessionId); | |
// μ»΄ν¬λνΈ μΈλ§μ΄νΈμ μ°κ²° μ’ λ£ | |
return () => { | |
eventSource.close(); | |
}; | |
} catch (err) { | |
console.error('μ ν λΆμ μ€λ₯:', err); | |
addLog(`Error occurred: ${err.message}`, 'error'); | |
setError(`Error during analysis: ${err.message}`); | |
setIsProcessing(false); | |
} | |
}; | |
// λΆμ κ²°κ³Ό λ λλ§ ν¨μ | |
const renderAnalysisResults = () => { | |
if (!analysisResults) return null; | |
// λΆμ μ νμ λ°λΌ λ€λ₯Έ κ²°κ³Ό νμ | |
switch (tabValue) { | |
case 0: // μ ν μ 보 νμ | |
return ( | |
<Card className={classes.resultCard}> | |
<CardContent> | |
<Typography variant="h6" gutterBottom>Product Information</Typography> | |
<Divider style={{ margin: '8px 0 16px' }} /> | |
{analysisResults.productInfo && ( | |
<div> | |
<Typography variant="subtitle1"> | |
<strong>Product Name:</strong> {analysisResults.productInfo.name} | |
</Typography> | |
<Typography variant="body1"> | |
<strong>Brand:</strong> {analysisResults.productInfo.brand} | |
</Typography> | |
<Typography variant="body1"> | |
<strong>Category:</strong> {analysisResults.productInfo.category} | |
</Typography> | |
<Typography variant="subtitle1" style={{ marginTop: '16px' }}>Key Specifications</Typography> | |
<ul> | |
{analysisResults.productInfo.specs.map((spec, index) => ( | |
<li key={index}> | |
<Typography variant="body2"> | |
<strong>{spec.name}:</strong> {spec.value} | |
</Typography> | |
</li> | |
))} | |
</ul> | |
<Typography variant="subtitle1" style={{ marginTop: '16px' }}>Pros</Typography> | |
<ul> | |
{analysisResults.productInfo.pros.map((pro, index) => ( | |
<li key={index}> | |
<Typography variant="body2">{pro}</Typography> | |
</li> | |
))} | |
</ul> | |
<Typography variant="subtitle1" style={{ marginTop: '16px' }}>Cons</Typography> | |
<ul> | |
{analysisResults.productInfo.cons.map((con, index) => ( | |
<li key={index}> | |
<Typography variant="body2">{con}</Typography> | |
</li> | |
))} | |
</ul> | |
</div> | |
)} | |
</CardContent> | |
</Card> | |
); | |
case 1: // μ ν μ±λ₯ λΉκ΅ | |
return ( | |
<Card className={classes.resultCard}> | |
<CardContent> | |
<Typography variant="h6" gutterBottom>Product Comparison Analysis</Typography> | |
<Divider style={{ margin: '8px 0 16px' }} /> | |
{analysisResults.comparison && ( | |
<div> | |
<Typography variant="subtitle1" gutterBottom>Product Specification Comparison</Typography> | |
<table className={classes.comparisonTable}> | |
<thead> | |
<tr> | |
<th>Feature</th> | |
<th>Product A</th> | |
<th>Product B</th> | |
<th>Comparison</th> | |
</tr> | |
</thead> | |
<tbody> | |
{analysisResults.comparison.specs.map((spec, index) => ( | |
<tr key={index}> | |
<td><strong>{spec.name}</strong></td> | |
<td>{spec.valueA}</td> | |
<td>{spec.valueB}</td> | |
<td> | |
<span className={spec.winner === 'A' ? classes.highlight : ''}> | |
{spec.comparison} | |
</span> | |
</td> | |
</tr> | |
))} | |
</tbody> | |
</table> | |
<Typography variant="subtitle1" style={{ marginTop: '16px' }}>Overall Comparison</Typography> | |
<Typography variant="body1">{analysisResults.comparison.summary}</Typography> | |
</div> | |
)} | |
</CardContent> | |
</Card> | |
); | |
case 2: // κ°κ²© λλΉ κ°μΉ λΆμ | |
return ( | |
<Card className={classes.resultCard}> | |
<CardContent> | |
<Typography variant="h6" gutterBottom>Price-to-Value Analysis</Typography> | |
<Divider style={{ margin: '8px 0 16px' }} /> | |
{analysisResults.valueAnalysis && ( | |
<div> | |
<Typography variant="subtitle1">Price Information</Typography> | |
<Typography variant="body1"> | |
<strong>Product A:</strong> {analysisResults.valueAnalysis.priceA} | |
</Typography> | |
{analysisResults.valueAnalysis.priceB && ( | |
<Typography variant="body1"> | |
<strong>Product B:</strong> {analysisResults.valueAnalysis.priceB} | |
</Typography> | |
)} | |
<Typography variant="subtitle1" style={{ marginTop: '16px' }}>Value Analysis</Typography> | |
<Typography variant="body1">{analysisResults.valueAnalysis.analysis}</Typography> | |
<Typography variant="subtitle1" style={{ marginTop: '16px' }}>Price-to-Performance Score</Typography> | |
<Typography variant="body1"> | |
<strong>Product A:</strong> {analysisResults.valueAnalysis.valueScoreA}/10 | |
</Typography> | |
{analysisResults.valueAnalysis.valueScoreB && ( | |
<Typography variant="body1"> | |
<strong>Product B:</strong> {analysisResults.valueAnalysis.valueScoreB}/10 | |
</Typography> | |
)} | |
</div> | |
)} | |
</CardContent> | |
</Card> | |
); | |
case 3: // μ΅μ ꡬ맀 μΆμ² | |
return ( | |
<Card className={classes.resultCard}> | |
<CardContent> | |
<Typography variant="h6" gutterBottom>Purchase Recommendations</Typography> | |
<Divider style={{ margin: '8px 0 16px' }} /> | |
{analysisResults.recommendation && ( | |
<div> | |
<Typography variant="subtitle1" className={classes.highlight}> | |
Recommended Product: {analysisResults.recommendation.recommendedProduct} | |
</Typography> | |
<Typography variant="body1" style={{ marginTop: '16px' }}> | |
{analysisResults.recommendation.reason} | |
</Typography> | |
<Typography variant="subtitle1" style={{ marginTop: '16px' }}>Alternative Products</Typography> | |
<ul> | |
{analysisResults.recommendation.alternatives.map((alt, index) => ( | |
<li key={index}> | |
<Typography variant="body2"> | |
<strong>{alt.name}</strong>: {alt.reason} | |
</Typography> | |
</li> | |
))} | |
</ul> | |
{analysisResults.recommendation.buyingTips && ( | |
<> | |
<Typography variant="subtitle1" style={{ marginTop: '16px' }}>Buying Tips</Typography> | |
<ul> | |
{analysisResults.recommendation.buyingTips.map((tip, index) => ( | |
<li key={index}> | |
<Typography variant="body2">{tip}</Typography> | |
</li> | |
))} | |
</ul> | |
</> | |
)} | |
</div> | |
)} | |
</CardContent> | |
</Card> | |
); | |
default: | |
return null; | |
} | |
}; | |
return ( | |
<Paper className={classes.root}> | |
<Box p={3}> | |
<Typography variant="h5" gutterBottom> | |
Product Comparison Analysis | |
</Typography> | |
<Typography variant="body1" paragraph> | |
Upload product images to receive detailed information and comparison analysis. | |
You can analyze various products including cars, smartphones, laptops, and more. | |
</Typography> | |
<Grid container spacing={3}> | |
{/* μ΄λ―Έμ§ μ λ‘λ μμ */} | |
{[0, 1].map((index) => ( | |
<Grid item xs={12} md={6} key={index}> | |
<Box className={classes.imageContainer}> | |
{imagePreviews[index] ? ( | |
<> | |
<img | |
src={imagePreviews[index]} | |
alt={`Product ${index + 1}`} | |
className={classes.imagePreview} | |
/> | |
<IconButton | |
className={classes.deleteButton} | |
onClick={() => handleImageDelete(index)} | |
> | |
<Delete /> | |
</IconButton> | |
</> | |
) : ( | |
<> | |
<input | |
accept="image/*" | |
className={classes.uploadInput} | |
id={`upload-image-${index}`} | |
type="file" | |
onChange={(e) => handleImageUpload(e, index)} | |
/> | |
<label htmlFor={`upload-image-${index}`}> | |
<Box display="flex" flexDirection="column" alignItems="center"> | |
<AddCircle className={classes.uploadIcon} /> | |
<Typography variant="body2" style={{ marginTop: '8px' }}> | |
Upload {index === 0 ? 'Product A' : 'Product B'} Image | |
</Typography> | |
</Box> | |
</label> | |
</> | |
)} | |
</Box> | |
</Grid> | |
))} | |
{/* λ€μ€ νμΌ μ λ‘λ (μ ν μ¬ν): λ μ₯μ ν λ²μ μ λ‘λ */} | |
<Grid item xs={12}> | |
<input | |
accept="image/*" | |
className={classes.uploadInput} | |
id="upload-both-images" | |
type="file" | |
multiple | |
onChange={(e) => { | |
const files = Array.from(e.target.files || []); | |
if (!files.length) return; | |
const newImages = [...images]; | |
const newPreviews = [...imagePreviews]; | |
files.slice(0, 2).forEach((file, idx) => { | |
const slot = idx; // 0,1 μμλ‘ μ±μ | |
newImages[slot] = file; | |
const reader = new FileReader(); | |
reader.onload = (ev) => { | |
newPreviews[slot] = ev.target.result; | |
setImagePreviews([...newPreviews]); | |
}; | |
reader.readAsDataURL(file); | |
}); | |
setImages(newImages); | |
setAnalysisResults(null); | |
setError(null); | |
}} | |
/> | |
<label htmlFor="upload-both-images"> | |
<Button variant="text" color="default" component="span"> | |
Or select two files at once | |
</Button> | |
</label> | |
</Grid> | |
{/* λΆμ μ ν ν */} | |
<Grid item xs={12}> | |
<Paper> | |
<Tabs | |
value={tabValue} | |
onChange={handleTabChange} | |
indicatorColor="primary" | |
textColor="primary" | |
centered | |
> | |
<Tab icon={<Info />} label="Product Info" /> | |
<Tab icon={<Compare />} label="Performance" disabled={!images[0] || !images[1]} /> | |
<Tab icon={<Search />} label="Value Analysis" /> | |
<Tab label="Recommendations" /> | |
</Tabs> | |
<Box p={2} display="flex" justifyContent="center" gridGap={12}> | |
<Button | |
variant="contained" | |
color="primary" | |
onClick={() => handleAnalysis(null)} | |
disabled={isProcessing || (!images[0] && !images[1])} | |
startIcon={isProcessing ? <CircularProgress size={24} /> : null} | |
> | |
{isProcessing ? 'Analyzing...' : 'Start Analysis'} | |
</Button> | |
<Button | |
variant="outlined" | |
color="secondary" | |
onClick={() => handleAnalysis('compare')} | |
disabled={isProcessing || !images[0] || !images[1]} | |
startIcon={<Compare />} | |
> | |
Compare Products | |
</Button> | |
</Box> | |
{/* μ€λ₯ λ©μμ§ νμ */} | |
{error && ( | |
<Box p={2} bgcolor="#ffebee" borderRadius="4px" mb={2}> | |
<Typography color="error">{error}</Typography> | |
</Box> | |
)} | |
</Paper> | |
</Grid> | |
{/* μ§ν κ³Όμ λ‘κ·Έ νμ */} | |
<Grid item xs={12}> | |
<Paper> | |
<Box p={2}> | |
<Typography variant="h6" gutterBottom> | |
Analysis Progress | |
</Typography> | |
<Box className={classes.progressLog}> | |
{progressLogs.length === 0 ? ( | |
<Typography variant="body2" color="textSecondary" style={{padding: '10px'}}> | |
Progress details will appear here when analysis starts. | |
</Typography> | |
) : ( | |
progressLogs.map((log, index) => ( | |
<Box | |
key={index} | |
className={`${classes.logEntry} ${log.type === 'agent' ? classes.logEntryAgent : log.type === 'system' ? classes.logEntrySystem : log.type === 'error' ? classes.logEntryError : ''}`} | |
> | |
<span className={classes.logTime}>[{log.time}]</span> | |
{log.message} | |
</Box> | |
)) | |
)} | |
<div ref={logEndRef} /> | |
</Box> | |
</Box> | |
</Paper> | |
</Grid> | |
{/* κ²°κ³Ό νμ μμ */} | |
<Grid item xs={12}> | |
{isProcessing ? ( | |
<Box className={classes.loadingContainer}> | |
<CircularProgress /> | |
<Typography variant="h6" style={{ marginTop: '16px' }}> | |
Analyzing Products... | |
</Typography> | |
<Typography variant="body2" color="textSecondary"> | |
Product recognition, information retrieval, and comparison analysis in progress. | |
</Typography> | |
</Box> | |
) : renderAnalysisResults()} | |
</Grid> | |
</Grid> | |
</Box> | |
</Paper> | |
); | |
}; | |
export default ProductComparison; | |