vision_llm_agent / frontend /src /components /ProductComparison.js
Sunhey Cho
Update vision LLM agent with React Product Comparison integration
207f9e0
raw
history blame
24.3 kB
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;