added token usage and cost to UI, cleanup
This commit is contained in:
parent
a85e96aad8
commit
bffb11e5b0
15 changed files with 285 additions and 254 deletions
|
|
@ -11,21 +11,21 @@ app.use(express.json());
|
|||
app.post('/api/claude', async (req, res) => {
|
||||
try {
|
||||
const { messages, apiKey, model, maxTokens } = req.body;
|
||||
|
||||
|
||||
console.log('=== Proxy received request ===');
|
||||
console.log('Model:', model);
|
||||
console.log('Max tokens:', maxTokens);
|
||||
console.log('Messages count:', messages?.length);
|
||||
console.log('API Key present:', !!apiKey);
|
||||
|
||||
|
||||
const requestBody = {
|
||||
model: model,
|
||||
max_tokens: maxTokens,
|
||||
messages: messages
|
||||
};
|
||||
|
||||
|
||||
console.log('Request body:', JSON.stringify(requestBody, null, 2));
|
||||
|
||||
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
|
@ -35,17 +35,18 @@ app.post('/api/claude', async (req, res) => {
|
|||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
|
||||
console.log('Claude API response status:', response.status);
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
console.error('Claude API error:', error);
|
||||
return res.status(response.status).json({ error });
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Claude API success, response content length:', data.content?.[0]?.text?.length);
|
||||
console.log('Token usage:', data.usage);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
console.error('Proxy error:', error);
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ import { lightTheme, darkTheme } from './theme';
|
|||
function App() {
|
||||
const [isDark, setIsDark] = useState(true);
|
||||
const debugMode = import.meta.env.REACT_APP_DEBUG_MODE === 'true';
|
||||
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={isDark ? darkTheme : lightTheme}>
|
||||
<CssBaseline />
|
||||
<Dashboard
|
||||
themeToggle={() => setIsDark(!isDark)}
|
||||
<Dashboard
|
||||
themeToggle={() => setIsDark(!isDark)}
|
||||
isDark={isDark}
|
||||
/>
|
||||
{debugMode && <DebugPanel />}
|
||||
|
|
|
|||
|
|
@ -9,42 +9,42 @@ function ChatInterface() {
|
|||
const [loading, setLoading] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || loading) return;
|
||||
|
||||
|
||||
const userMessage = { role: 'user', content: input };
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
setIsExpanded(true); // Expand chat when sending a message
|
||||
|
||||
|
||||
try {
|
||||
const response = await sendChatMessage(input, messages);
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: response }]);
|
||||
} catch (error) {
|
||||
console.error('Chat error:', error);
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: 'Sorry, I encountered an error. Please try again.'
|
||||
setMessages(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: 'Sorry, I encountered an error. Please try again.'
|
||||
}]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
|
||||
return (
|
||||
<Paper
|
||||
<Paper
|
||||
elevation={8}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: isExpanded ? '40vh' : '64px',
|
||||
transition: 'height 0.3s ease',
|
||||
display: 'flex',
|
||||
|
|
@ -53,14 +53,14 @@ function ChatInterface() {
|
|||
}}
|
||||
>
|
||||
{/* Expand/Collapse Button */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
p: 1,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider'
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
p: 1,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider'
|
||||
}}>
|
||||
<IconButton
|
||||
<IconButton
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
size="small"
|
||||
>
|
||||
|
|
@ -70,7 +70,7 @@ function ChatInterface() {
|
|||
Chat with Claude
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
|
||||
{/* Messages area - only visible when expanded */}
|
||||
{isExpanded && (
|
||||
<Box sx={{ flex: 1, overflow: 'auto', p: 2 }}>
|
||||
|
|
@ -80,16 +80,16 @@ function ChatInterface() {
|
|||
</Typography>
|
||||
)}
|
||||
{messages.map((msg, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
sx={{
|
||||
mb: 2,
|
||||
textAlign: msg.role === 'user' ? 'right' : 'left'
|
||||
<Box
|
||||
key={idx}
|
||||
sx={{
|
||||
mb: 2,
|
||||
textAlign: msg.role === 'user' ? 'right' : 'left'
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
display: 'inline-block',
|
||||
bgcolor: msg.role === 'user' ? 'primary.main' : 'grey.800',
|
||||
color: 'white',
|
||||
|
|
@ -105,13 +105,13 @@ function ChatInterface() {
|
|||
<div ref={messagesEndRef} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
{/* Input area - always visible */}
|
||||
<Box sx={{
|
||||
p: 2,
|
||||
borderTop: isExpanded ? 1 : 0,
|
||||
borderColor: 'divider',
|
||||
display: 'flex',
|
||||
<Box sx={{
|
||||
p: 2,
|
||||
borderTop: isExpanded ? 1 : 0,
|
||||
borderColor: 'divider',
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
backgroundColor: 'background.paper'
|
||||
}}>
|
||||
|
|
@ -129,8 +129,8 @@ function ChatInterface() {
|
|||
disabled={loading}
|
||||
size="small"
|
||||
/>
|
||||
<IconButton
|
||||
onClick={handleSend}
|
||||
<IconButton
|
||||
onClick={handleSend}
|
||||
disabled={loading || !input.trim()}
|
||||
color="primary"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -16,17 +16,18 @@ function Dashboard({ themeToggle, isDark }) {
|
|||
const [sourceData, setSourceData] = useState({});
|
||||
const [error, setError] = useState(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const [tokenUsage, setTokenUsage] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard();
|
||||
}, []);
|
||||
|
||||
|
||||
async function loadDashboard(forceRefresh = false) {
|
||||
try {
|
||||
console.log('=== loadDashboard called ===');
|
||||
console.log('Force refresh:', forceRefresh);
|
||||
setError(null);
|
||||
|
||||
|
||||
// Check if we already have today's briefing (unless forcing refresh)
|
||||
if (!forceRefresh && hasTodaysBriefing()) {
|
||||
console.log('Using cached briefing from today');
|
||||
|
|
@ -36,53 +37,63 @@ function Dashboard({ themeToggle, isDark }) {
|
|||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
console.log('Fetching fresh data...');
|
||||
setLoadingStatus('Fetching weather and news sources...');
|
||||
|
||||
|
||||
// Fetch all configured sources
|
||||
const data = await fetchAllSources();
|
||||
console.log('Fetched sources:', Object.keys(data.sources || {}));
|
||||
console.log('Failed sources:', data.failed);
|
||||
setSourceData(data.sources || {});
|
||||
|
||||
|
||||
// If all sources failed, show error but continue
|
||||
if (data.failed > 0 && data.successful === 0) {
|
||||
setError('All data sources failed to load. Please check your internet connection and try again.');
|
||||
} else if (data.failed > 0) {
|
||||
setError(`${data.failed} data source(s) failed to load. Showing available data.`);
|
||||
}
|
||||
|
||||
|
||||
// Check if API key is configured
|
||||
const apiKey = import.meta.env.REACT_APP_ANTHROPIC_API_KEY;
|
||||
console.log('API key configured:', !!apiKey);
|
||||
|
||||
|
||||
if (!apiKey || apiKey === 'sk-ant-your-key-here') {
|
||||
console.warn('API key not configured properly');
|
||||
setBriefing('Please add your Anthropic API key to .env.local to generate briefings.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Get yesterday's briefing for comparison
|
||||
const previousBriefing = getPreviousBriefing(1);
|
||||
console.log('Previous briefing exists:', !!previousBriefing);
|
||||
|
||||
|
||||
// Only generate briefing if we have some data
|
||||
if (Object.keys(data.sources || {}).length > 0) {
|
||||
console.log('Calling generateBriefing...');
|
||||
setLoadingStatus('Generating your personalized briefing with AI...');
|
||||
|
||||
|
||||
try {
|
||||
// Generate today's briefing with Claude
|
||||
const newBriefing = await generateBriefing(data, previousBriefing);
|
||||
console.log('Briefing generated, length:', newBriefing?.length);
|
||||
setBriefing(newBriefing);
|
||||
|
||||
const result = await generateBriefing(data, previousBriefing);
|
||||
console.log('Briefing generated');
|
||||
|
||||
// Handle result object with text and usage
|
||||
if (result && typeof result === 'object' && result.text) {
|
||||
setBriefing(result.text);
|
||||
setTokenUsage(result.usage);
|
||||
console.log('Token usage:', result.usage);
|
||||
} else if (typeof result === 'string') {
|
||||
// Fallback for string responses
|
||||
setBriefing(result);
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
console.log('Saving briefing for:', today);
|
||||
saveBriefing(today, newBriefing, data.sources);
|
||||
const briefingText = result?.text || result;
|
||||
saveBriefing(today, briefingText, data.sources);
|
||||
} catch (briefingError) {
|
||||
console.error('Failed to generate briefing:', briefingError);
|
||||
setBriefing(`Unable to generate AI briefing: ${briefingError.message}\n\nYou can still view the data widgets below.`);
|
||||
|
|
@ -90,7 +101,7 @@ function Dashboard({ themeToggle, isDark }) {
|
|||
} else {
|
||||
setBriefing('No data sources loaded successfully. Please check your configuration and try again.');
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Dashboard load error:', error);
|
||||
console.error('Error stack:', error.stack);
|
||||
|
|
@ -100,12 +111,12 @@ function Dashboard({ themeToggle, isDark }) {
|
|||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true);
|
||||
loadDashboard(true);
|
||||
};
|
||||
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box display="flex" flexDirection="column" justifyContent="center" alignItems="center" minHeight="100vh" gap={2}>
|
||||
|
|
@ -116,20 +127,20 @@ function Dashboard({ themeToggle, isDark }) {
|
|||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', pb: 10 }}>
|
||||
{/* Top toolbar */}
|
||||
<Box sx={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
right: 0,
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
zIndex: 100
|
||||
<Box sx={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
right: 0,
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
zIndex: 100
|
||||
}}>
|
||||
<IconButton
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
localStorage.clear();
|
||||
window.location.reload();
|
||||
|
|
@ -139,56 +150,70 @@ function Dashboard({ themeToggle, isDark }) {
|
|||
>
|
||||
🗑️
|
||||
</IconButton>
|
||||
<IconButton
|
||||
<IconButton
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
title="Refresh data"
|
||||
>
|
||||
<Refresh />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={themeToggle}
|
||||
<IconButton
|
||||
onClick={themeToggle}
|
||||
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{isDark ? <Brightness7 /> : <Brightness4 />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
|
||||
<Container maxWidth="xl" sx={{ pt: 4 }}>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
|
||||
{tokenUsage && (
|
||||
<Box sx={{ mb: 2, p: 2, bgcolor: 'background.paper', borderRadius: 1, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>API Usage:</strong> {tokenUsage.input_tokens?.toLocaleString()} input + {tokenUsage.output_tokens?.toLocaleString()} output = {(tokenUsage.input_tokens + tokenUsage.output_tokens).toLocaleString()} total tokens
|
||||
{' • '}
|
||||
<strong>Cost:</strong> ${((tokenUsage.input_tokens * 0.003 / 1000) + (tokenUsage.output_tokens * 0.015 / 1000)).toFixed(4)}
|
||||
{' '}
|
||||
<Typography component="span" variant="caption" color="text.disabled">
|
||||
(Claude Sonnet 4 pricing: $0.003/1K input, $0.015/1K output)
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Widget Grid - easily rearrangeable */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 2,
|
||||
mb: 4
|
||||
}}>
|
||||
<BriefingWidget briefing={briefing} sx={{ flex: '1 1 100%' }} />
|
||||
|
||||
<WeatherWidget
|
||||
data={sourceData.weather}
|
||||
sx={{ flex: '1 1 300px' }}
|
||||
|
||||
<WeatherWidget
|
||||
data={sourceData.weather}
|
||||
sx={{ flex: '1 1 300px' }}
|
||||
/>
|
||||
|
||||
|
||||
{/* Render NewsWidget for each news source */}
|
||||
{Object.entries(sourceData).map(([key, data]) => {
|
||||
if (key === 'weather') return null;
|
||||
return (
|
||||
<NewsWidget
|
||||
<NewsWidget
|
||||
key={key}
|
||||
data={data}
|
||||
sx={{ flex: '1 1 400px' }}
|
||||
data={data}
|
||||
sx={{ flex: '1 1 400px' }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
|
||||
{/* Fixed chat at bottom */}
|
||||
<ChatInterface />
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { exportAllData, importDummyData } from '../services/storageService';
|
|||
|
||||
function DebugPanel() {
|
||||
const [dummyMode, setDummyMode] = useState(false);
|
||||
|
||||
|
||||
const handleExport = () => {
|
||||
const data = exportAllData();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
|
|
@ -16,11 +16,11 @@ function DebugPanel() {
|
|||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
|
||||
const handleImport = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
|
|
@ -35,40 +35,40 @@ function DebugPanel() {
|
|||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Paper sx={{ position: 'fixed', bottom: 80, right: 16, p: 2, width: 250, zIndex: 999 }}>
|
||||
<Typography variant="h6" gutterBottom>Debug Tools</Typography>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
startIcon={<Download />}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
startIcon={<Download />}
|
||||
onClick={handleExport}
|
||||
sx={{ mb: 1 }}
|
||||
variant="outlined"
|
||||
>
|
||||
Export Data
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
component="label"
|
||||
startIcon={<Upload />}
|
||||
variant="outlined"
|
||||
>
|
||||
Import Dummy Data
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
accept=".json"
|
||||
onChange={handleImport}
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
accept=".json"
|
||||
onChange={handleImport}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
|
||||
{dummyMode && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="warning.main"
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="warning.main"
|
||||
sx={{ mt: 1, display: 'block' }}
|
||||
>
|
||||
Using dummy data
|
||||
|
|
|
|||
|
|
@ -3,15 +3,15 @@ import GenericWidget from './GenericWidget';
|
|||
|
||||
function BriefingWidget({ briefing, sx }) {
|
||||
return (
|
||||
<GenericWidget
|
||||
title="Your Daily Briefing"
|
||||
<GenericWidget
|
||||
title="Your Daily Briefing"
|
||||
sx={{ ...sx }}
|
||||
>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{briefing ? (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
whiteSpace: 'pre-line',
|
||||
lineHeight: 1.7,
|
||||
fontSize: '1.05rem'
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ import { Paper, Typography, Box } from '@mui/material';
|
|||
|
||||
function GenericWidget({ title, data, children, sx, ...props }) {
|
||||
return (
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
p: 2,
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
p: 2,
|
||||
minWidth: 300,
|
||||
...sx
|
||||
}}
|
||||
...sx
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom>{title}</Typography>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ function NewsWidget({ data, sx }) {
|
|||
// Handle Reddit data
|
||||
if (data.type === 'reddit' && data.posts) {
|
||||
const subreddit = data.url?.match(/\/r\/([^\/\.]+)/)?.[1] || 'Reddit';
|
||||
|
||||
|
||||
return (
|
||||
<GenericWidget title={`r/${subreddit}`} sx={{ ...sx }}>
|
||||
<List dense>
|
||||
|
|
@ -49,7 +49,7 @@ function NewsWidget({ data, sx }) {
|
|||
href={post.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
sx={{
|
||||
textDecoration: 'none',
|
||||
color: 'text.primary',
|
||||
'&:hover': { textDecoration: 'underline' }
|
||||
|
|
@ -65,17 +65,17 @@ function NewsWidget({ data, sx }) {
|
|||
secondaryTypographyProps={{ component: 'div' }}
|
||||
secondary={
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 0.5 }}>
|
||||
<Chip
|
||||
icon={<TrendingUp />}
|
||||
label={post.score}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
<Chip
|
||||
icon={<TrendingUp />}
|
||||
label={post.score}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
icon={<Comment />}
|
||||
label={post.comments}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
<Chip
|
||||
icon={<Comment />}
|
||||
label={post.comments}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 1, alignSelf: 'center' }}>
|
||||
by {post.author}
|
||||
|
|
@ -124,7 +124,7 @@ function NewsWidget({ data, sx }) {
|
|||
href={item.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
sx={{
|
||||
textDecoration: 'none',
|
||||
color: 'text.primary',
|
||||
'&:hover': { textDecoration: 'underline' }
|
||||
|
|
@ -144,10 +144,10 @@ function NewsWidget({ data, sx }) {
|
|||
{item.pubDate && new Date(item.pubDate).toLocaleDateString()}
|
||||
</Typography>
|
||||
{item.description && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
display: '-webkit-box',
|
||||
mt: 0.5,
|
||||
overflow: 'hidden',
|
||||
|
|
|
|||
|
|
@ -47,21 +47,21 @@ function WeatherWidget({ data, sx }) {
|
|||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
{description}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Chip
|
||||
label={`Humidity: ${humidity}%`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
<Chip
|
||||
label={`Humidity: ${humidity}%`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
label={`Wind: ${windSpeed} ${windUnit}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
<Chip
|
||||
label={`Wind: ${windSpeed} ${windUnit}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
ANTHROPIC_API_URL,
|
||||
DEFAULT_CLAUDE_MODEL,
|
||||
MAX_TOKENS
|
||||
import {
|
||||
ANTHROPIC_API_URL,
|
||||
DEFAULT_CLAUDE_MODEL,
|
||||
MAX_TOKENS
|
||||
} from '../utils/constants';
|
||||
|
||||
/**
|
||||
|
|
@ -12,22 +12,22 @@ import {
|
|||
*/
|
||||
async function callClaude(prompt, messages = []) {
|
||||
const apiKey = import.meta.env.REACT_APP_ANTHROPIC_API_KEY;
|
||||
|
||||
|
||||
console.log('=== callClaude function ===');
|
||||
console.log('API Key configured:', !!apiKey);
|
||||
console.log('API Key starts with:', apiKey?.substring(0, 15) + '...');
|
||||
|
||||
|
||||
if (!apiKey || apiKey === 'sk-ant-your-key-here') {
|
||||
console.error('Anthropic API key not configured');
|
||||
return 'Please configure your Anthropic API key in .env.local';
|
||||
}
|
||||
|
||||
|
||||
const model = import.meta.env.REACT_APP_CLAUDE_MODEL || DEFAULT_CLAUDE_MODEL;
|
||||
console.log('Using model:', model);
|
||||
console.log('Max tokens:', MAX_TOKENS);
|
||||
console.log('Prompt length:', prompt.length);
|
||||
console.log('Previous messages:', messages.length);
|
||||
|
||||
|
||||
try {
|
||||
const requestBody = {
|
||||
apiKey: apiKey,
|
||||
|
|
@ -38,10 +38,10 @@ async function callClaude(prompt, messages = []) {
|
|||
{ role: 'user', content: prompt }
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
console.log('Sending request to proxy...');
|
||||
console.log('Request messages:', requestBody.messages.map(m => ({ role: m.role, contentLength: m.content.length })));
|
||||
|
||||
|
||||
// Use local proxy server instead of direct API call
|
||||
const response = await fetch('http://localhost:3001/api/claude', {
|
||||
method: 'POST',
|
||||
|
|
@ -50,19 +50,24 @@ async function callClaude(prompt, messages = []) {
|
|||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
|
||||
console.log('Proxy response status:', response.status);
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
console.error('Proxy error response:', error);
|
||||
throw new Error(`Claude API error: ${response.status} - ${error}`);
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Response data structure:', Object.keys(data));
|
||||
console.log('Response content:', data.content?.[0]?.text?.substring(0, 200) + '...');
|
||||
return data.content[0].text;
|
||||
console.log('Usage data:', data.usage);
|
||||
|
||||
return {
|
||||
text: data.content[0].text,
|
||||
usage: data.usage // { input_tokens, output_tokens }
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error calling Claude:', error);
|
||||
throw error;
|
||||
|
|
@ -77,14 +82,14 @@ async function callClaude(prompt, messages = []) {
|
|||
*/
|
||||
function buildBriefingPrompt(currentData, previousBriefing) {
|
||||
const customInstructions = import.meta.env.REACT_APP_CLAUDE_INSTRUCTIONS;
|
||||
|
||||
|
||||
let prompt = '';
|
||||
|
||||
|
||||
// Prepend custom instructions if provided
|
||||
if (customInstructions && customInstructions.trim()) {
|
||||
prompt += `CUSTOM INSTRUCTIONS: ${customInstructions}\n\n`;
|
||||
}
|
||||
|
||||
|
||||
prompt += `You are creating a personalized daily briefing. Be conversational and highlight what's interesting.
|
||||
|
||||
CURRENT DATA:
|
||||
|
|
@ -126,19 +131,19 @@ export async function generateBriefing(currentData, previousBriefing) {
|
|||
if (!currentData || Object.keys(currentData.sources || currentData).length === 0) {
|
||||
return 'No data available to generate briefing. Please check your data sources configuration.';
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const prompt = buildBriefingPrompt(currentData, previousBriefing);
|
||||
const briefing = await callClaude(prompt);
|
||||
return briefing;
|
||||
const result = await callClaude(prompt);
|
||||
return result; // Returns { text, usage }
|
||||
} catch (error) {
|
||||
console.error('Error generating briefing:', error);
|
||||
|
||||
|
||||
// Return a fallback briefing
|
||||
return `Good morning! I encountered an issue generating your personalized briefing today.
|
||||
|
||||
return `Good morning! I encountered an issue generating your personalized briefing today.
|
||||
|
||||
Error: ${error.message}
|
||||
|
||||
|
||||
Please check your API key configuration and try again.`;
|
||||
}
|
||||
}
|
||||
|
|
@ -153,23 +158,23 @@ export async function sendChatMessage(message, conversationHistory = []) {
|
|||
if (!message || !message.trim()) {
|
||||
throw new Error('Message cannot be empty');
|
||||
}
|
||||
|
||||
|
||||
// Build context from conversation history
|
||||
const messages = conversationHistory.map(msg => ({
|
||||
role: msg.role === 'user' ? 'user' : 'assistant',
|
||||
content: msg.content
|
||||
}));
|
||||
|
||||
|
||||
// Add context about the dashboard
|
||||
const contextPrompt = `You are a helpful assistant embedded in a personal dashboard application.
|
||||
You have access to the user's daily briefing data and can help answer questions about news, weather,
|
||||
const contextPrompt = `You are a helpful assistant embedded in a personal dashboard application.
|
||||
You have access to the user's daily briefing data and can help answer questions about news, weather,
|
||||
and other information sources configured in their dashboard. Be conversational and helpful.
|
||||
|
||||
|
||||
User's message: ${message}`;
|
||||
|
||||
|
||||
try {
|
||||
const response = await callClaude(contextPrompt, messages);
|
||||
return response;
|
||||
const result = await callClaude(contextPrompt, messages);
|
||||
return result.text; // For chat, just return text for now
|
||||
} catch (error) {
|
||||
console.error('Error in chat:', error);
|
||||
return `I apologize, but I encountered an error: ${error.message}. Please try again.`;
|
||||
|
|
@ -183,23 +188,23 @@ export async function sendChatMessage(message, conversationHistory = []) {
|
|||
* @returns {Promise<Object>} Extracted content
|
||||
*/
|
||||
export async function extractContentFromHTML(html, url) {
|
||||
const prompt = `Extract the main content from this HTML page.
|
||||
const prompt = `Extract the main content from this HTML page.
|
||||
Focus on the primary article, news content, or main information.
|
||||
Ignore navigation, ads, and other peripheral content.
|
||||
|
||||
|
||||
URL: ${url}
|
||||
|
||||
|
||||
HTML (truncated to first 10000 chars):
|
||||
${html.substring(0, 10000)}
|
||||
|
||||
|
||||
Please provide a JSON response with:
|
||||
- title: The main title
|
||||
- summary: A brief summary (2-3 sentences)
|
||||
- mainContent: The main text content (up to 500 words)
|
||||
- metadata: Any relevant metadata (author, date, etc.)
|
||||
|
||||
|
||||
Respond with valid JSON only.`;
|
||||
|
||||
|
||||
try {
|
||||
const response = await callClaude(prompt);
|
||||
// Try to parse as JSON
|
||||
|
|
@ -236,7 +241,7 @@ export async function summarizeData(data, dataType) {
|
|||
${JSON.stringify(data, null, 2)}
|
||||
|
||||
Be concise and focus on what would be most relevant to someone checking their daily news.`;
|
||||
|
||||
|
||||
try {
|
||||
return await callClaude(prompt);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { parseSource, standardizeSourceData } from './sourceParser';
|
||||
import { getCachedData, cacheSourceData } from './storageService';
|
||||
import { fetchWithCors } from '../utils/corsProxy';
|
||||
import {
|
||||
DEFAULT_DATA_SOURCES,
|
||||
import {
|
||||
DEFAULT_DATA_SOURCES,
|
||||
WEATHER_API_BASE,
|
||||
DEFAULT_REFRESH_INTERVAL
|
||||
DEFAULT_REFRESH_INTERVAL
|
||||
} from '../utils/constants';
|
||||
|
||||
/**
|
||||
|
|
@ -33,23 +33,23 @@ function getCacheKey(url) {
|
|||
*/
|
||||
export async function fetchSingleSource(url) {
|
||||
const cacheKey = getCacheKey(url);
|
||||
|
||||
|
||||
// Check cache first
|
||||
const cached = getCachedData(cacheKey);
|
||||
if (cached) {
|
||||
console.log(`Using cached data for ${url}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
console.log(`Fetching fresh data from ${url}`);
|
||||
const data = await parseSource(url);
|
||||
const standardized = standardizeSourceData(data, url);
|
||||
|
||||
|
||||
// Cache the result
|
||||
const ttl = parseInt(import.meta.env.REACT_APP_REFRESH_INTERVAL) || DEFAULT_REFRESH_INTERVAL;
|
||||
cacheSourceData(cacheKey, standardized, ttl);
|
||||
|
||||
|
||||
return standardized;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${url}:`, error);
|
||||
|
|
@ -73,24 +73,24 @@ export async function fetchWeather(location) {
|
|||
console.warn('No location configured for weather');
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
const cacheKey = `weather_${location.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
||||
|
||||
|
||||
// Check cache first
|
||||
const cached = getCachedData(cacheKey);
|
||||
if (cached) {
|
||||
console.log(`Using cached weather data for ${location}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Format location for wttr.in
|
||||
const formattedLocation = encodeURIComponent(location);
|
||||
const weatherUrl = `${WEATHER_API_BASE}/${formattedLocation}?format=j1`;
|
||||
|
||||
|
||||
console.log(`Fetching weather for ${location}`);
|
||||
const data = await fetchWithCors(weatherUrl);
|
||||
|
||||
|
||||
// Standardize weather data
|
||||
const weatherData = {
|
||||
type: 'weather',
|
||||
|
|
@ -100,11 +100,11 @@ export async function fetchWeather(location) {
|
|||
nearestArea: data.nearest_area?.[0] || {},
|
||||
fetchedAt: Date.now()
|
||||
};
|
||||
|
||||
|
||||
// Cache the result
|
||||
const ttl = parseInt(import.meta.env.REACT_APP_REFRESH_INTERVAL) || DEFAULT_REFRESH_INTERVAL;
|
||||
cacheSourceData(cacheKey, weatherData, ttl);
|
||||
|
||||
|
||||
return weatherData;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching weather for ${location}:`, error);
|
||||
|
|
@ -120,22 +120,22 @@ export async function fetchAllSources() {
|
|||
// Get configured sources from environment or use defaults
|
||||
const sourcesEnv = import.meta.env.REACT_APP_DATA_SOURCES;
|
||||
const sources = sourcesEnv ? parseEnvArray(sourcesEnv) : DEFAULT_DATA_SOURCES;
|
||||
|
||||
|
||||
// Get location for weather
|
||||
const location = import.meta.env.REACT_APP_LOCATION;
|
||||
|
||||
|
||||
console.log('Fetching from sources:', sources);
|
||||
console.log('Weather location:', location);
|
||||
|
||||
|
||||
// Create fetch promises
|
||||
const promises = sources.map(url =>
|
||||
const promises = sources.map(url =>
|
||||
fetchSingleSource(url).catch(error => ({
|
||||
error: true,
|
||||
url: url,
|
||||
message: error.message
|
||||
}))
|
||||
);
|
||||
|
||||
|
||||
// Add weather fetch if location is configured
|
||||
if (location) {
|
||||
promises.push(
|
||||
|
|
@ -146,14 +146,14 @@ export async function fetchAllSources() {
|
|||
}))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Fetch all in parallel
|
||||
const results = await Promise.allSettled(promises);
|
||||
|
||||
|
||||
// Process results
|
||||
const sourceData = {};
|
||||
const errors = [];
|
||||
|
||||
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
const data = result.value;
|
||||
|
|
@ -193,12 +193,12 @@ export async function fetchAllSources() {
|
|||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Log any errors
|
||||
if (errors.length > 0) {
|
||||
console.warn('Some sources failed to fetch:', errors);
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
sources: sourceData,
|
||||
errors: errors,
|
||||
|
|
@ -233,7 +233,7 @@ export async function fetchHackerNewsStories(storyIds, limit = 10) {
|
|||
const idsToFetch = storyIds.slice(0, limit);
|
||||
const promises = idsToFetch.map(id => fetchHackerNewsStory(id));
|
||||
const results = await Promise.allSettled(promises);
|
||||
|
||||
|
||||
return results
|
||||
.filter(r => r.status === 'fulfilled' && r.value)
|
||||
.map(r => r.value);
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ import { RSS_TO_JSON_API } from '../utils/constants';
|
|||
*/
|
||||
export function detectSourceType(url) {
|
||||
const urlLower = url.toLowerCase();
|
||||
|
||||
|
||||
if (urlLower.endsWith('.json')) return 'json';
|
||||
if (urlLower.endsWith('.rss') ||
|
||||
urlLower.endsWith('.xml') ||
|
||||
if (urlLower.endsWith('.rss') ||
|
||||
urlLower.endsWith('.xml') ||
|
||||
urlLower.includes('/feed') ||
|
||||
urlLower.includes('rss')) return 'rss';
|
||||
|
||||
|
||||
return 'html'; // Default to HTML scraping
|
||||
}
|
||||
|
||||
|
|
@ -44,18 +44,18 @@ export async function parseRSS(url) {
|
|||
// Use RSS to JSON service
|
||||
const rssUrl = `${RSS_TO_JSON_API}?rss_url=${encodeURIComponent(url)}`;
|
||||
const response = await fetch(rssUrl);
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`RSS parse failed: ${response.status}`);
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
// Check if RSS2JSON returned an error
|
||||
if (data.status !== 'ok') {
|
||||
throw new Error(data.message || 'RSS feed parsing failed');
|
||||
}
|
||||
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Error parsing RSS from ${url}:`, error);
|
||||
|
|
@ -72,7 +72,7 @@ export async function parseRSS(url) {
|
|||
export async function parseHTML(url, corsProxy) {
|
||||
try {
|
||||
const html = await fetchWithCors(url);
|
||||
|
||||
|
||||
// Return structured data for Claude to process
|
||||
return {
|
||||
url: url,
|
||||
|
|
@ -94,17 +94,17 @@ export async function parseHTML(url, corsProxy) {
|
|||
*/
|
||||
export async function parseSource(url, type = null) {
|
||||
const sourceType = type || detectSourceType(url);
|
||||
|
||||
|
||||
switch(sourceType) {
|
||||
case 'json':
|
||||
return await parseJSON(url);
|
||||
|
||||
|
||||
case 'rss':
|
||||
return await parseRSS(url);
|
||||
|
||||
|
||||
case 'html':
|
||||
return await parseHTML(url);
|
||||
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown source type: ${sourceType}`);
|
||||
}
|
||||
|
|
@ -153,7 +153,7 @@ export function standardizeSourceData(data, sourceUrl) {
|
|||
before: data.data.before
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (sourceUrl.includes('hacker-news') && Array.isArray(data)) {
|
||||
// Hacker News top stories (just IDs)
|
||||
return {
|
||||
|
|
@ -163,7 +163,7 @@ export function standardizeSourceData(data, sourceUrl) {
|
|||
fetchedAt: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (data.items && data.feed) {
|
||||
// RSS feed format from rss2json
|
||||
return {
|
||||
|
|
@ -181,7 +181,7 @@ export function standardizeSourceData(data, sourceUrl) {
|
|||
}))
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Default format for unknown sources
|
||||
return {
|
||||
type: 'unknown',
|
||||
|
|
|
|||
|
|
@ -34,15 +34,15 @@ function saveStorage(storage) {
|
|||
*/
|
||||
export function saveBriefing(date, briefingText, sourceData) {
|
||||
const storage = getStorage();
|
||||
|
||||
|
||||
storage.briefings[date] = {
|
||||
timestamp: Date.now(),
|
||||
briefing: briefingText,
|
||||
sources: sourceData
|
||||
};
|
||||
|
||||
|
||||
saveStorage(storage);
|
||||
|
||||
|
||||
// Clean up old data after saving
|
||||
cleanupOldData();
|
||||
}
|
||||
|
|
@ -57,7 +57,7 @@ export function getPreviousBriefing(daysAgo = 1) {
|
|||
const date = new Date();
|
||||
date.setDate(date.getDate() - daysAgo);
|
||||
const dateKey = date.toISOString().split('T')[0];
|
||||
|
||||
|
||||
return storage.briefings[dateKey] || null;
|
||||
}
|
||||
|
||||
|
|
@ -69,13 +69,13 @@ export function getPreviousBriefing(daysAgo = 1) {
|
|||
*/
|
||||
export function cacheSourceData(sourceKey, data, ttlMinutes = CACHE_TTL) {
|
||||
const storage = getStorage();
|
||||
|
||||
|
||||
storage.cache[sourceKey] = {
|
||||
data: data,
|
||||
timestamp: Date.now(),
|
||||
ttl: ttlMinutes * 60 * 1000 // Convert to milliseconds
|
||||
};
|
||||
|
||||
|
||||
saveStorage(storage);
|
||||
}
|
||||
|
||||
|
|
@ -87,21 +87,21 @@ export function cacheSourceData(sourceKey, data, ttlMinutes = CACHE_TTL) {
|
|||
export function getCachedData(sourceKey) {
|
||||
const storage = getStorage();
|
||||
const cached = storage.cache[sourceKey];
|
||||
|
||||
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
const now = Date.now();
|
||||
const age = now - cached.timestamp;
|
||||
|
||||
|
||||
if (age > cached.ttl) {
|
||||
// Cache expired
|
||||
delete storage.cache[sourceKey];
|
||||
saveStorage(storage);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
|
|
@ -112,10 +112,10 @@ export function getCachedData(sourceKey) {
|
|||
export function cleanupOldData(daysToKeep = null) {
|
||||
const storage = getStorage();
|
||||
const days = daysToKeep || parseInt(import.meta.env.REACT_APP_HISTORY_DAYS) || DEFAULT_HISTORY_DAYS;
|
||||
|
||||
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - days);
|
||||
|
||||
|
||||
// Clean old briefings
|
||||
if (storage.briefings) {
|
||||
Object.keys(storage.briefings).forEach(date => {
|
||||
|
|
@ -124,7 +124,7 @@ export function cleanupOldData(daysToKeep = null) {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Clean expired cache entries
|
||||
const now = Date.now();
|
||||
if (storage.cache) {
|
||||
|
|
@ -135,7 +135,7 @@ export function cleanupOldData(daysToKeep = null) {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
saveStorage(storage);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { DEFAULT_CORS_PROXY } from './constants';
|
|||
*/
|
||||
export async function fetchWithCors(url, options = {}) {
|
||||
const proxy = import.meta.env.REACT_APP_CORS_PROXY || DEFAULT_CORS_PROXY;
|
||||
|
||||
|
||||
try {
|
||||
// Try direct fetch first
|
||||
const response = await fetch(url, {
|
||||
|
|
@ -19,11 +19,11 @@ export async function fetchWithCors(url, options = {}) {
|
|||
'Accept': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return await response.json();
|
||||
|
|
@ -33,15 +33,15 @@ export async function fetchWithCors(url, options = {}) {
|
|||
} catch (error) {
|
||||
// If CORS fails, try with proxy
|
||||
console.warn(`CORS failed for ${url}, trying proxy...`, error.message);
|
||||
|
||||
|
||||
try {
|
||||
const proxiedUrl = `${proxy}${encodeURIComponent(url)}`;
|
||||
const response = await fetch(proxiedUrl, options);
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return await response.json();
|
||||
|
|
@ -82,7 +82,7 @@ export async function getAccessibleUrl(url) {
|
|||
if (canAccess) {
|
||||
return url;
|
||||
}
|
||||
|
||||
|
||||
const proxy = import.meta.env.REACT_APP_CORS_PROXY || DEFAULT_CORS_PROXY;
|
||||
return `${proxy}${encodeURIComponent(url)}`;
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import react from '@vitejs/plugin-react'
|
|||
export default defineConfig(({ mode }) => {
|
||||
// Load env file based on `mode` in the current working directory.
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
|
||||
// Expose REACT_APP_ prefixed variables to import.meta.env
|
||||
const processEnvValues = {
|
||||
'process.env': Object.entries(env).reduce(
|
||||
|
|
@ -18,7 +18,7 @@ export default defineConfig(({ mode }) => {
|
|||
{},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
define: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue