diff --git a/nginx.conf b/nginx.conf index 9ec3a38..89d75bc 100644 --- a/nginx.conf +++ b/nginx.conf @@ -23,4 +23,10 @@ server { access_log off; add_header Cache-Control "public"; } + # Proxy Anthropic API to bypass CORS + location /api/anthropic/ { + proxy_pass https://api.anthropic.com/; + proxy_set_header Host api.anthropic.com; + proxy_ssl_server_name on; + } } diff --git a/src/App.jsx b/src/App.jsx index ace264f..80d12a2 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -7,10 +7,10 @@ import Login from './pages/Login' import Dashboard from './pages/Dashboard' import Admin from './pages/Admin' +import Leren from './pages/Leren' // Placeholder components for routing structure const Testen = () =>

Weektest

Start your weekly test here.

-const Leren = () =>

Leren

Start your weekly learning session.

const Leaderboard = () =>

Leaderboard

See who is on top!

// Protected Route Wrapper diff --git a/src/lib/api.js b/src/lib/api.js index cfea2fa..5aeefd8 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -2,47 +2,52 @@ import { storage } from './storage'; /** * Anthropic API Service - * Handles communication with the /v1/messages endpoint. + * Handles communication with the /v1/messages endpoint via Nginx proxy. */ -const MODEL = 'claude-3-7-sonnet-20250219'; // using the latest sonnet model for best results. Alternatively, claude-3-5-sonnet-20241022 or the prompt's requested claude-sonnet-4-20250514 (which might be a pseudo-name for the upcoming 3.7 or future version). +const DEFAULT_MODEL = 'claude-sonnet-4-20250514'; export const anthropicApi = { - /** - * Call the Anthropic API with a system prompt and user message. - * Includes a basic retry mechanism. - */ async generateContent(systemPrompt, userMessage, maxRetries = 1) { - let retries = 0; - - // In a real application, the API key should not be exposed to the client. - // For this prototype, we'll assume it's passed or available in the environment, - // or proxied via a secure endpoint if deployed. - // Prioritize key from local storage (set via Admin Settings) - const apiKey = storage.get('admin:anthropic_key') || import.meta.env.VITE_ANTHROPIC_API_KEY || 'no-key-provided'; + // Check if simulation mode is on + const useSimulation = storage.get('admin:use_simulation') === true; + if (useSimulation) { + console.log('[API] Simulatie mode actief. Mock data wordt geretourneerd.'); + return await simulateResponse(); + } + const apiKey = storage.get('admin:anthropic_key') || import.meta.env.VITE_ANTHROPIC_API_KEY; + if (!apiKey) { + throw new Error('Geen Anthropic API key gevonden. Ga naar Admin -> Settings.'); + } + + // Model is configurable from Admin > Settings, defaults to the original spec model + const model = storage.get('admin:model') || DEFAULT_MODEL; + console.log(`[API] Aanroep met model: ${model}`); + + let retries = 0; while (retries <= maxRetries) { try { - const response = await fetch('https://api.anthropic.com/v1/messages', { + const response = await fetch('/api/anthropic/v1/messages', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', - 'anthropic-dangerous-direct-browser-access': 'true' // Required for client-side fetch + 'anthropic-dangerous-direct-browser-access': 'true' }, body: JSON.stringify({ - model: MODEL, + model: model, max_tokens: 4000, system: systemPrompt, - messages: [ - { role: 'user', content: userMessage } - ] + messages: [{ role: 'user', content: userMessage }] }) }); if (!response.ok) { - throw new Error(`API Error: ${response.status} ${response.statusText}`); + const errData = await response.json().catch(() => ({})); + console.error('[API] Error response:', errData); + throw new Error(`API Error: ${response.status} ${response.statusText} - ${JSON.stringify(errData)}`); } const data = await response.json(); @@ -50,12 +55,24 @@ export const anthropicApi = { } catch (error) { console.error('API call failed:', error); retries++; - if (retries > maxRetries) { - throw new Error('Failed to generate content after retries.'); - } - // Small delay before retry - await new Promise(resolve => setTimeout(resolve, 1000)); + if (retries > maxRetries) throw error; + await new Promise(r => setTimeout(r, 1000)); } } } }; + +async function simulateResponse() { + await new Promise(r => setTimeout(r, 2000)); + return JSON.stringify({ + topics: [ + { id: "radicale-transparantie", label: "Radicale Transparantie", type: "concept", description: "De kernwaarde van Respellion waarbij alle informatie publiek toegankelijk is." }, + { id: "kennisbeheer", label: "Kennisbeheer", type: "process", description: "Het proces van het vastleggen en ontsluiten van organisatiekennis." }, + { id: "wekelijkse-sessie", label: "Wekelijkse Leersessie", type: "process", description: "Elke week leren medewerkers via AI-gegenereerde vragen en quizzen." } + ], + relations: [ + { source: "kennisbeheer", target: "radicale-transparantie", type: "depends_on" }, + { source: "wekelijkse-sessie", target: "kennisbeheer", type: "part_of" } + ] + }); +} diff --git a/src/lib/learningService.js b/src/lib/learningService.js new file mode 100644 index 0000000..cf79fed --- /dev/null +++ b/src/lib/learningService.js @@ -0,0 +1,91 @@ +import { anthropicApi } from './api'; +import { storage } from './storage'; + +const CONTENT_GENERATION_SYSTEM = `Je bent een expert leerinhoud-schrijver voor Respellion, een intern IT-bedrijf. +Je schrijft leermateriaal voor medewerkers op basis van kennisonderwerpen. +Schrijf altijd in het Nederlands, helder en professioneel. +Geef ALTIJD geldige JSON terug, zonder markdown code-blokken.`; + +/** + * Get the assigned topic for a user for a given week using round-robin. + * hash(userId + weekNumber) % topicCount + */ +export function getAssignedTopic(userId, weekNumber) { + const topics = storage.get('kb:topics', []); + if (!topics || topics.length === 0) return null; + + // Simple deterministic hash + const str = `${userId}:${weekNumber}`; + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) - hash + str.charCodeAt(i); + hash |= 0; // Convert to 32-bit integer + } + const index = Math.abs(hash) % topics.length; + return topics[index]; +} + +/** + * Generate a complete learning module for a topic. + * Returns an object with { article, slides, podcastScript, infographic }. + * Caches results in storage. + */ +export async function generateLearningContent(topic) { + const cacheKey = `kb:content:${topic.id}`; + const cached = storage.get(cacheKey); + if (cached) { + console.log(`[Learn] Cache hit voor topic: ${topic.id}`); + return cached; + } + + const prompt = `Genereer een compleet leermodule voor het volgende onderwerp: + +Label: ${topic.label} +Type: ${topic.type} +Beschrijving: ${topic.description} + +Geef ALLEEN een JSON object terug met de volgende structuur: +{ + "article": { + "title": "Artikel titel", + "intro": "Korte intro van 1-2 zinnen", + "sections": [ + { "heading": "Sectietitel", "body": "Sectietekst van minimaal 3 zinnen." } + ], + "keyTakeaways": ["Lespunt 1", "Lespunt 2", "Lespunt 3"] + }, + "slides": [ + { "title": "Diatitel", "bullets": ["Punt 1", "Punt 2", "Punt 3"], "speakerNote": "Toelichting voor de spreker." } + ], + "podcastScript": "Een vloeiend gesproken script van ca. 300 woorden dat de inhoud samenvat als een podcast.", + "infographic": { + "headline": "Een korte, krachtige zin die het onderwerp samenvat (max 8 woorden)", + "tagline": "Een subkop van max 15 woorden", + "stats": [ + { "value": "Getal of %", "label": "Korte omschrijving", "icon": "📊" } + ], + "steps": [ + { "number": 1, "title": "Staptitel", "description": "Korte beschrijving van 1 zin.", "icon": "🔑" } + ], + "quote": "Een inspirerende of kernachtige quote over het onderwerp.", + "colorTheme": "teal" + } +} + +Zorg voor minimaal 3 secties in het artikel, 4 slides, 3 statistieken en 3-5 stappen in de infographic.`; + + + const responseText = await anthropicApi.generateContent(CONTENT_GENERATION_SYSTEM, prompt); + + let content; + try { + const jsonMatch = responseText.match(/\{[\s\S]*\}/); + content = JSON.parse(jsonMatch ? jsonMatch[0] : responseText); + } catch (e) { + throw new Error('AI kon geen geldige leerinhoud genereren.'); + } + + // Cache the content + storage.set(cacheKey, content); + return content; +} diff --git a/src/pages/Admin/index.jsx b/src/pages/Admin/index.jsx index c4d9439..0225993 100644 --- a/src/pages/Admin/index.jsx +++ b/src/pages/Admin/index.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { Database, FileText, Settings, Users, Network, Clock, CheckCircle2, AlertCircle, Save } from 'lucide-react'; +import { Database, FileText, Settings, Users, Network, Clock, CheckCircle2, AlertCircle, Save, Info } from 'lucide-react'; import Card from '../../components/ui/Card'; import Tag from '../../components/ui/Tag'; import Button from '../../components/ui/Button'; @@ -12,6 +12,8 @@ const Admin = () => { const [activeTab, setActiveTab] = useState('bronnen'); const [sources, setSources] = useState([]); const [apiKey, setApiKey] = useState(''); + const [model, setModel] = useState(''); + const [useSimulation, setUseSimulation] = useState(false); const [saveStatus, setSaveStatus] = useState(null); const loadSources = () => { @@ -24,12 +26,16 @@ const Admin = () => { } if (activeTab === 'instellingen') { setApiKey(storage.get('admin:anthropic_key', '')); + setModel(storage.get('admin:model', 'claude-sonnet-4-20250514')); + setUseSimulation(storage.get('admin:use_simulation', false)); } }, [activeTab]); const saveSettings = (e) => { e.preventDefault(); - storage.set('admin:anthropic_key', apiKey); + storage.set('admin:anthropic_key', apiKey.trim()); + storage.set('admin:model', model.trim()); + storage.set('admin:use_simulation', useSimulation); setSaveStatus('Opgeslagen!'); setTimeout(() => setSaveStatus(null), 3000); }; @@ -153,12 +159,45 @@ const Admin = () => { value={apiKey} onChange={(e) => setApiKey(e.target.value)} /> +
+ setModel(e.target.value)} + /> +

Laat leeg voor de standaard. Controleer je Anthropic Console voor beschikbare modellen.

+

- Deze sleutel wordt lokaal in je browser opgeslagen (`localStorage`) en wordt gebruikt voor alle AI-interacties. + Deze sleutel wordt lokaal in je browser opgeslagen (`localStorage`).

+ +
+
+
+

Simulatie Mode

+

Gebruik gesimuleerde AI-antwoorden als de API niet werkt.

+
+ +
+ {useSimulation && ( +
+ +

Wanneer ingeschakeld, zal het platform doen alsof de AI teksten verwerkt zonder een echte API-aanroep te doen. Ideaal voor UI testen.

+
+ )} +
-
+
diff --git a/src/pages/Leren.jsx b/src/pages/Leren.jsx new file mode 100644 index 0000000..9cf5d1a --- /dev/null +++ b/src/pages/Leren.jsx @@ -0,0 +1,391 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { ChevronLeft, ChevronRight, BookOpen, Presentation, Mic, CheckCircle, Loader, ArrowRight, Volume2, VolumeX, BarChart2 } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import Card from '../components/ui/Card'; +import Button from '../components/ui/Button'; +import Tag from '../components/ui/Tag'; +import { useApp } from '../store/AppContext'; +import { getAssignedTopic, generateLearningContent } from '../lib/learningService'; +import { storage } from '../lib/storage'; + +const Leren = () => { + const { state } = useApp(); + const [topic, setTopic] = useState(null); + const [content, setContent] = useState(null); + const [activeMode, setActiveMode] = useState('artikel'); // 'artikel' | 'slides' | 'podcast' + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [completed, setCompleted] = useState(false); + + useEffect(() => { + if (state.currentUser) { + const assigned = getAssignedTopic(state.currentUser.id, state.weekNumber); + setTopic(assigned); + } + }, [state.currentUser, state.weekNumber]); + + const loadContent = async () => { + if (!topic) return; + setIsLoading(true); + setError(null); + try { + const generated = await generateLearningContent(topic); + setContent(generated); + } catch (e) { + setError(e.message); + } finally { + setIsLoading(false); + } + }; + + const handleComplete = () => { + setCompleted(true); + storage.set(`user:${state.currentUser.id}:week:${state.weekNumber}:learn_done`, true); + }; + + if (!topic) { + return ( +
+ +

Leerstation

+

Er zijn nog geen kennisonderwerpen beschikbaar. Vraag een admin om bronmateriaal te uploaden.

+
+ ); + } + + if (completed) { + return ( +
+ + + +

Leersessie voltooid!

+

Je hebt "{topic.label}" succesvol doorgenomen. Ga nu de weektest maken!

+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ Week {state.weekNumber} + {topic.type} +
+

{topic.label}

+

{topic.description}

+
+ + {/* Mode Selector */} + {content && ( +
+ {[ + { key: 'artikel', icon: BookOpen, label: 'Artikel' }, + { key: 'slides', icon: Presentation, label: 'Slides' }, + { key: 'podcast', icon: Mic, label: 'Podcast' }, + { key: 'infographic', icon: BarChart2, label: 'Infographic' }, + ].map(({ key, icon: Icon, label }) => ( + + ))} +
+ )} + + {/* Content Area */} + {!content && !isLoading && ( + + +

Klik op de knop om je gepersonaliseerde leerinhoud te genereren met AI.

+ +
+ )} + + {isLoading && ( + + +

AI genereert je gepersonaliseerde leerinhoud...

+

Dit kan 10-30 seconden duren.

+
+ )} + + {error && ( + +

Fout bij genereren

+

{error}

+ +
+ )} + + + {content && ( + + {activeMode === 'artikel' && } + {activeMode === 'slides' && } + {activeMode === 'podcast' && } + {activeMode === 'infographic' && } + + )} + + + {content && !completed && ( +
+ +
+ )} +
+ ); +}; + +/* ── Article Renderer ─────────────────────────────────────── */ +const ArticleView = ({ content }) => ( +
+ +

{content.title}

+

{content.intro}

+
+ + {content.sections?.map((section, i) => ( + +

{section.heading}

+

{section.body}

+
+ ))} + + {content.keyTakeaways?.length > 0 && ( + +

Kernpunten

+
    + {content.keyTakeaways.map((point, i) => ( +
  • + → + {point} +
  • + ))} +
+
+ )} +
+); + +/* ── Slides Renderer ──────────────────────────────────────── */ +const SlidesView = ({ slides }) => { + const [current, setCurrent] = useState(0); + const total = slides?.length || 0; + + return ( +
+ + + +
+
+ {current + 1} / {total} + Slide +
+

{slides[current]?.title}

+
    + {slides[current]?.bullets?.map((bullet, i) => ( +
  • + â–¸ + {bullet} +
  • + ))} +
+
+ {slides[current]?.speakerNote && ( +

+ 💬 {slides[current].speakerNote} +

+ )} +
+
+
+ +
+ +
+ {slides?.map((_, i) => ( +
+ +
+
+ ); +}; + +/* ── Podcast Renderer ─────────────────────────────────────── */ +const PodcastView = ({ script, topicLabel }) => { + const [isPlaying, setIsPlaying] = useState(false); + const utteranceRef = useRef(null); + + const togglePlayback = () => { + if (!('speechSynthesis' in window)) { + alert('Text-to-speech wordt niet ondersteund door je browser.'); + return; + } + + if (isPlaying) { + window.speechSynthesis.cancel(); + setIsPlaying(false); + } else { + const utterance = new SpeechSynthesisUtterance(script); + utterance.lang = 'nl-NL'; + utterance.rate = 0.95; + utterance.onend = () => setIsPlaying(false); + utteranceRef.current = utterance; + window.speechSynthesis.speak(utterance); + setIsPlaying(true); + } + }; + + // Cleanup on unmount + useEffect(() => () => window.speechSynthesis?.cancel(), []); + + return ( + +
+
+ +
+
+

Podcast Aflevering

+

{topicLabel}

+
+ +
+ + {isPlaying && ( +
+
+ {[0.3, 0.7, 1, 0.6, 0.4, 0.8, 0.5].map((h, i) => ( +
+ ))} +
+ Wordt afgespeeld... +
+ )} + +
+

Script

+

{script}

+
+ + ); +}; + +/* ── Infographic Renderer ─────────────────────────────────── */ +const InfographicView = ({ data, topicLabel }) => { + if (!data) { + return ( + + Geen infographic data beschikbaar voor dit onderwerp. + + ); + } + + return ( +
+ {/* Hero Header */} +
+
+
+

{topicLabel}

+

{data.headline}

+

{data.tagline}

+
+
+ + {/* Stats Row */} + {data.stats?.length > 0 && ( +
+ {data.stats.map((stat, i) => ( + + +
{stat.icon}
+
{stat.value}
+
{stat.label}
+
+
+ ))} +
+ )} + + {/* Process Steps */} + {data.steps?.length > 0 && ( + +

Stappen & Processen

+
+ {data.steps.map((step, i) => ( + +
+ {step.icon} +
+
+
+ 0{step.number} +

{step.title}

+
+

{step.description}

+
+
+ ))} +
+
+ )} + + {/* Quote */} + {data.quote && ( +
+
+ "{data.quote}" +
+
+ )} +
+ ); +}; + +export default Leren; diff --git a/vite.config.js b/vite.config.js index c4069b7..8ef1402 100644 --- a/vite.config.js +++ b/vite.config.js @@ -5,4 +5,13 @@ import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + server: { + proxy: { + '/api/anthropic': { + target: 'https://api.anthropic.com', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/anthropic/, '') + } + } + } })