diff --git a/README.md b/README.md index 0f4d04c..f906235 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,15 @@ A local-only React application that aggregates data from multiple sources and uses Claude AI to generate a personalized daily briefing. Features a persistent chat interface and configurable data widgets. Give it sources to parse, and generate a summary of the news you care about. ## Main Features: -- Customizable daily briefing (tone, sources, length, etc) with optional vocalization (read-aloud) +- Customizable daily briefing with advanced configuration options: + - Configurable briefing length (e.g., 200-300, 300-400 words) + - Keyword/topic emphasis for prioritizing specific subjects + - Per-row Claude instructions for customized tone per source + - Time-of-day adaptive tone (energizing mornings, reflective evenings) + - Optional vocalization with text-to-speech (read-aloud) + - Copy to clipboard and download as Markdown - Changes since past briefings referenced in current briefing if relevant +- Briefing history widget with visualization of word count trends over time - Claude cost and usage details for each briefing (Sonnet 4.5 = $.05-.10 avg per briefing, depending on sources used) - Local storage of session information (no data sent to 3rd parties except Claude / open-meteo) - Localized weather via open-meteo with charts @@ -81,6 +88,9 @@ All configuration is managed through environment variables in `.env.local`. Copy | `REACT_APP_DATA_SOURCES` | Reddit tech/worldnews/science | JSON object for row-based layout or comma-separated URLs | | `REACT_APP_CLAUDE_MODEL` | claude-sonnet-4-5-20250929 | Claude model identifier | | `REACT_APP_CLAUDE_INSTRUCTIONS` | None | Custom instructions for briefing generation | +| `REACT_APP_BRIEFING_LENGTH` | 300-400 | Target word count for briefings (e.g., "200-300", "400-500") | +| `REACT_APP_BRIEFING_TOPICS` | None | Comma-separated keywords/topics to prioritize (e.g., "AI, climate, space") | +| `REACT_APP_ROW_INSTRUCTIONS` | None | JSON object with per-row Claude instructions (e.g., {"News":"be terse","Games":"be casual"}) | | `REACT_APP_REFRESH_INTERVAL` | 15 | Cache TTL in minutes | | `REACT_APP_HISTORY_DAYS` | 7 | Days of briefing history to retain | | `REACT_APP_CORS_PROXY` | allorigins.win | CORS proxy service URL | diff --git a/claudash/.env.example b/claudash/.env.example index 8f2be83..26ef726 100644 --- a/claudash/.env.example +++ b/claudash/.env.example @@ -31,6 +31,18 @@ REACT_APP_CLAUDE_MODEL=claude-3-5-sonnet-20241022 # Example: "Focus on world news more than tech news. Use a concise and informative tone." REACT_APP_CLAUDE_INSTRUCTIONS= +# Optional: Configurable briefing length (default: 300-400) +# Example: "200-300", "400-500", "150-200" +REACT_APP_BRIEFING_LENGTH=300-400 + +# Optional: Keywords/topics to prioritize in briefing (comma-separated) +# Example: "AI, climate change, space exploration" +REACT_APP_BRIEFING_TOPICS= + +# Optional: Per-row instructions as JSON object (keys must match row names in DATA_SOURCES) +# Example: {"News":"be terse and factual","Games":"be casual and enthusiastic"} +REACT_APP_ROW_INSTRUCTIONS= + # Optional: Debug mode (shows raw data, enables dummy data) REACT_APP_DEBUG_MODE=false diff --git a/claudash/src/components/widgets/BriefingWidget.jsx b/claudash/src/components/widgets/BriefingWidget.jsx index 033d0a6..ce673d4 100644 --- a/claudash/src/components/widgets/BriefingWidget.jsx +++ b/claudash/src/components/widgets/BriefingWidget.jsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react'; -import { Skeleton, Box, IconButton, Collapse, FormControlLabel, Switch, ToggleButtonGroup, ToggleButton, Select, MenuItem, FormControl, InputLabel, Typography } from '@mui/material'; -import { VolumeUp, PlayArrow, Pause, Settings, Stop } from '@mui/icons-material'; +import { Skeleton, Box, IconButton, Collapse, FormControlLabel, Switch, ToggleButtonGroup, ToggleButton, Select, MenuItem, FormControl, InputLabel, Typography, Snackbar } from '@mui/material'; +import { VolumeUp, PlayArrow, Pause, Settings, Stop, ContentCopy, Download } from '@mui/icons-material'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import GenericWidget from './GenericWidget'; @@ -13,6 +13,8 @@ function BriefingWidget({ briefing, sx }) { const [playbackSpeed, setPlaybackSpeed] = useState(1); const [voices, setVoices] = useState([]); const [selectedVoice, setSelectedVoice] = useState(''); + const [snackbarOpen, setSnackbarOpen] = useState(false); + const [snackbarMessage, setSnackbarMessage] = useState(''); const utteranceRef = useRef(null); // Load available voices @@ -155,6 +157,42 @@ function BriefingWidget({ briefing, sx }) { setShowControls(!showControls); }; + const handleCopy = async () => { + if (!briefing) return; + + try { + await navigator.clipboard.writeText(briefing); + setSnackbarMessage('Briefing copied to clipboard!'); + setSnackbarOpen(true); + } catch (error) { + console.error('Failed to copy:', error); + setSnackbarMessage('Failed to copy briefing'); + setSnackbarOpen(true); + } + }; + + const handleDownload = () => { + if (!briefing) return; + + const blob = new Blob([briefing], { type: 'text/markdown' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const date = new Date().toISOString().split('T')[0]; + a.download = `briefing-${date}.md`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + setSnackbarMessage('Briefing downloaded!'); + setSnackbarOpen(true); + }; + + const handleSnackbarClose = () => { + setSnackbarOpen(false); + }; + // Cleanup on unmount useEffect(() => { return () => { @@ -164,11 +202,25 @@ function BriefingWidget({ briefing, sx }) { return ( + + + + + + {!showControls && !isPlaying && ( )} + + {/* Snackbar for copy/download feedback */} + ); } diff --git a/claudash/src/services/claudeService.js b/claudash/src/services/claudeService.js index c3232b1..dcbf59a 100644 --- a/claudash/src/services/claudeService.js +++ b/claudash/src/services/claudeService.js @@ -109,10 +109,28 @@ CURRENT DATA: debugLog('Condensed data size:', JSON.stringify(condensedData).length); debugLog('Size reduction:', Math.round((1 - JSON.stringify(condensedData).length / JSON.stringify(currentData).length) * 100) + '%'); - // Add all condensed source data + // Get optional per-row instructions from env + let rowInstructions = {}; + const rowInstructionsEnv = import.meta.env.REACT_APP_ROW_INSTRUCTIONS; + if (rowInstructionsEnv && rowInstructionsEnv.trim()) { + try { + rowInstructions = JSON.parse(rowInstructionsEnv); + debugLog('Parsed row instructions:', rowInstructions); + } catch (error) { + console.error('Failed to parse REACT_APP_ROW_INSTRUCTIONS:', error); + } + } + + // Add all condensed source data with optional per-row instructions Object.entries(condensedData).forEach(([source, data]) => { if (data && !data.error) { - prompt += `\n${source.toUpperCase()}:\n${JSON.stringify(data, null, 2)}\n`; + // Check if there are specific instructions for this row + const rowInstruction = rowInstructions[source]; + if (rowInstruction) { + prompt += `\n${source.toUpperCase()} [Instructions: ${rowInstruction}]:\n${JSON.stringify(data, null, 2)}\n`; + } else { + prompt += `\n${source.toUpperCase()}:\n${JSON.stringify(data, null, 2)}\n`; + } } }); @@ -122,14 +140,42 @@ CURRENT DATA: prompt += `\nHighlight what's changed, any follow-up stories, or interesting trends.`; } - prompt += `\n\nCreate a friendly briefing (300-400 words) that: -- Starts with a warm greeting appropriate for the time of day + // Get configurable briefing length from env (default: 300-400 words) + const briefingLength = import.meta.env.REACT_APP_BRIEFING_LENGTH || '300-400'; + + // Get optional keyword/topic emphasis from env + const topicEmphasis = import.meta.env.REACT_APP_BRIEFING_TOPICS; + const topicInstruction = topicEmphasis && topicEmphasis.trim() + ? `\n\nPRIORITY TOPICS: Pay special attention to and prioritize stories related to: ${topicEmphasis}\n` + : ''; + + // Detect time of day for tone adjustment + const hour = new Date().getHours(); + let timeOfDay, toneGuidance; + + if (hour >= 5 && hour < 12) { + timeOfDay = 'morning'; + toneGuidance = 'Use an energizing, upbeat tone to help start the day. Focus on what lies ahead.'; + } else if (hour >= 12 && hour < 17) { + timeOfDay = 'afternoon'; + toneGuidance = 'Use a balanced, informative tone. Provide updates on what\'s happened so far today.'; + } else if (hour >= 17 && hour < 21) { + timeOfDay = 'evening'; + toneGuidance = 'Use a relaxed, reflective tone. Summarize the day\'s events and what to look forward to.'; + } else { + timeOfDay = 'night'; + toneGuidance = 'Use a calm, concise tone. Keep it brief and focus on the most important highlights.'; + } + + prompt += `\n\nCreate a friendly briefing (${briefingLength} words) that: +- Starts with a warm greeting appropriate for ${timeOfDay} +- ${toneGuidance} - Highlights the most interesting items from the data - Notes anything that's changed since yesterday (if applicable) - Has personality and isn't just a dry list - Includes relevant weather information if available - Ends with something engaging or thought-provoking - +${topicInstruction} Focus on the most newsworthy and interesting items. Don't try to mention everything. IMPORTANT: Provide your response as valid JSON with exactly two fields: diff --git a/claudash/src/services/sourceParser.js b/claudash/src/services/sourceParser.js index 9b55c6e..769bddc 100644 --- a/claudash/src/services/sourceParser.js +++ b/claudash/src/services/sourceParser.js @@ -26,6 +26,19 @@ export function detectSourceType(url) { */ export async function parseJSON(url, corsProxy) { try { + // Reddit's JSON API blocks unauthenticated browser CORS requests (returns 403). + // Route through the local proxy server, which fetches server-side with a proper + // User-Agent header that Reddit accepts. + if (url.includes('reddit.com')) { + const proxyUrl = `http://localhost:3001/api/scrape?url=${encodeURIComponent(url)}`; + const response = await fetch(proxyUrl); + if (!response.ok) { + throw new Error(`Local proxy returned ${response.status} for Reddit URL`); + } + const text = await response.text(); + return JSON.parse(text); + } + const data = await fetchWithCors(url); return data; } catch (error) {