/** * Scaffo.ai - Main App Component * Contains all state management, routing, and event handlers. * Loaded via Babel standalone - uses var declarations for global scope. * * Dependencies: React, ReactDOM, auth.js, api.js, utils.js, components.jsx */ var { createRoot } = ReactDOM; var App = () => { // ============================================ // ALL HOOKS MUST BE AT THE TOP (React Rules) // ============================================ // Auth State const [currentUser, setCurrentUser] = useState(null); const [authChecked, setAuthChecked] = useState(false); // App State (must be declared before any conditional returns) const [view, setView] = useState('home'); // home, chat, blueprint, generating, package, library const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(''); const [isLoading, setIsLoading] = useState(false); const [aiProgress, setAiProgress] = useState(null); // { message, step, total_steps } const [isGeneratingMaterials, setIsGeneratingMaterials] = useState(false); const [isGeneratingBlueprint, setIsGeneratingBlueprint] = useState(false); const [blueprint, setBlueprint] = useState(null); const [teachingPackage, setTeachingPackage] = useState(null); const [extractedInfo, setExtractedInfo] = useState(null); const [confirmedLessonInfo, setConfirmedLessonInfo] = useState(null); const [adjustModalOpen, setAdjustModalOpen] = useState(false); const [adjustSection, setAdjustSection] = useState(null); const [generatingPhase, setGeneratingPhase] = useState(''); const [generatingProgress, setGeneratingProgress] = useState(0); const [generatingSteps, setGeneratingSteps] = useState([]); const [currentGenerationId, setCurrentGenerationId] = useState(null); const [currentGeneration, setCurrentGeneration] = useState(null); const [showPackageInGeneration, setShowPackageInGeneration] = useState(false); const [uploadedFiles, setUploadedFiles] = useState([]); const [pendingAttachments, setPendingAttachments] = useState([]); const [isUploading, setIsUploading] = useState(false); const [blueprintModifiedSinceGeneration, setBlueprintModifiedSinceGeneration] = useState(false); const [lastGeneratedBlueprint, setLastGeneratedBlueprint] = useState(null); // Snapshot of blueprint when materials were last generated const [recentGenerations, setRecentGenerations] = useState([]); // Refs const chatRef = useRef(null); const fileInputRef = useRef(null); // URL routing helper (defined early so useEffect can use it) const navigateTo = (path) => { window.history.pushState({}, '', `/#${path}`); window.dispatchEvent(new PopStateEvent('popstate')); }; // Parse hash route // Supports: /#/generation/:id or /#/library // Note: userId is NOT in URL for privacy - backend identifies user via auth token const parseRoute = () => { const hash = window.location.hash.slice(1) || '/'; const parts = hash.split('/').filter(Boolean); // Format: /generation/:id or /library return { path: parts[0] || 'home', id: parts[1] || null }; }; // Load recent generations on mount and after generation const loadRecentGenerations = async () => { try { const result = await api.getGenerations(1, 10, ''); setRecentGenerations(result.items || []); } catch (error) { console.error('Failed to load recent generations:', error); } }; // ============================================ // ALL useEffect HOOKS (must be before any conditional returns) // ============================================ // Check authentication on mount useEffect(() => { const checkAuth = async () => { const storedUser = getStoredUser(); const token = getAuthToken(); if (storedUser && token) { // Verify token is still valid try { const user = await api.getCurrentUser(); setCurrentUser(user); } catch (e) { // Token invalid, clear it clearAuthToken(); } } setAuthChecked(true); }; checkAuth(); }, []); // Handle route changes useEffect(() => { // Skip route handling if not authenticated yet if (!authChecked || !currentUser) return; const handleRoute = async () => { const { path, id } = parseRoute(); if (path === 'generation' && id) { // Load generation by ID // User is identified via auth token, not URL try { const gen = await api.getGeneration(id); if (gen) { setCurrentGenerationId(id); setCurrentGeneration(gen); // Store full generation for history // Load conversation and add blueprint as a message const conversation = gen.conversation || []; // Restore attachments from legacy conversation (for old records without structured attachments) // This parses "📎 Attached:" markers in content to reconstruct attachment preview cards // Also restore thoughtText from thought_text (backend uses snake_case, frontend uses camelCase) const restoredConversation = conversation.map(function(msg) { // Convert thought_text to thoughtText for frontend display var restoredMsg = { ...msg }; if (msg.thought_text) { restoredMsg.thoughtText = msg.thought_text; } // Skip attachment restoration if message already has attachments or is not a user message if (msg.attachments || msg.role !== 'user') return restoredMsg; // Check if content contains attached file indicators var content = msg.content || ''; var attachedMatch = content.match(/📎 Attached:\s*(.+)$/m); if (!attachedMatch) return restoredMsg; // Parse attached files from content (format: "📎 Attached: 🖼️ filename.png" or "📎 Attached: 📝 filename.docx") var attachedPart = attachedMatch[1].trim(); var attachments = []; // Determine file type by checking for image emoji (🖼️ or 🖼) at the start var isImage = /^🖼/.test(attachedPart); // Extract filename by removing the leading emoji // Handle various emoji formats: 🖼️ (U+1F5BC + U+FE0F), 📝 (U+1F4DD), 📄 (U+1F4C4), 📊 (U+1F4CA) // Use Unicode property escape for reliable emoji matching var filename = attachedPart.replace(/^[\u{1F3A8}\u{1F4DD}\u{1F4C4}\u{1F4CA}\u{1F5BC}]\uFE0F?\s*/u, '').trim(); if (filename) { // Also check file extension to determine type var isImageByExt = /\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i.test(filename); attachments.push({ filename: filename, analysis_type: (isImage || isImageByExt) ? 'image' : 'document', // Note: file_url may not be available for legacy records file_url: null }); } if (attachments.length > 0) { return { ...restoredMsg, attachments: attachments }; } return restoredMsg; }); const messagesWithBlueprint = [...restoredConversation]; // Add blueprint as a message if it exists and not already in messages if (gen.blueprint) { // Check if this is a regeneration scenario (has package history) var hasPackageHistory = gen.package_history && gen.package_history.length > 0; // Filter out redundant messages from conversation that we'll replace with proper components const filteredMessages = restoredConversation.filter(msg => { // Remove plain "Blueprint generated" text (we'll add blueprint component instead) if (msg.content === 'Blueprint generated' && !msg.type) return false; // Remove "Perfect! Generating..." messages (transient) if (msg.content && msg.content.includes('Generating your lesson blueprint')) return false; // For regeneration scenario: filter out old blueprint_ready and package messages // We'll reconstruct them properly with version info if (hasPackageHistory) { if (msg.type === 'blueprint_ready') return false; if (msg.type === 'package') return false; if (msg.type === 'blueprint') return false; } return true; }); // Separate regeneration messages from other messages // They should be placed AFTER history, not before var regenMessages = []; var nonRegenMessages = []; filteredMessages.forEach(function(m) { if (m.content && ( m.content.includes('I\'ve modified the Blueprint') || m.content.includes('我已修改 Blueprint') || m.content.includes('Got it! I noticed you\'ve modified') || m.content.includes('好的,我注意到您修改了') || m.content.includes('Regenerating teaching materials') || m.content.includes('正在根据更新后的内容重新生成') )) { regenMessages.push(m); } else { nonRegenMessages.push(m); } }); // Replace messagesWithBlueprint with non-regen messages first messagesWithBlueprint.length = 0; nonRegenMessages.forEach(m => messagesWithBlueprint.push(m)); // Check if there's a blueprint message WITH actual blueprint data // (saved conversation may have type='blueprint' but no blueprint object) var hasBlueprintMsg = filteredMessages.some(msg => msg.type === 'blueprint' && msg.blueprint); // If there's a blueprint message without data, enrich it with gen.blueprint // Also restore thoughtText if it's missing (for old data before the bug fix) for (var i = 0; i < messagesWithBlueprint.length; i++) { var m = messagesWithBlueprint[i]; if (m.type === 'blueprint' && gen.blueprint) { // Enrich with blueprint data if missing if (!m.blueprint) { messagesWithBlueprint[i] = { ...m, blueprint: gen.blueprint, generationId: gen.id, blueprintHistory: gen.blueprint_history || [], version: gen.version || 1, updatedAt: gen.updated_at, // CRITICAL: Restore thoughtText from blueprint for thinking log display thoughtText: gen.blueprint.thought_text || null }; // Mark as having blueprint message now hasBlueprintMsg = true; } // Also restore thoughtText if message has blueprint but missing thoughtText else if (!m.thoughtText && gen.blueprint.thought_text) { messagesWithBlueprint[i] = { ...m, thoughtText: gen.blueprint.thought_text }; } } // Restore extractedInfo for confirmation messages from blueprint metadata if (m.type === 'confirmation' && !m.extractedInfo && gen.blueprint) { var metadata = gen.blueprint.metadata || {}; messagesWithBlueprint[i] = { ...m, extractedInfo: { topic: metadata.topic || gen.blueprint.title || gen.title, grade: metadata.grade_level || '', subject: metadata.subject || '', duration: metadata.duration_minutes ? String(metadata.duration_minutes) : '', teaching_style: metadata.teaching_style || '', activities: metadata.activities || [], objectives: metadata.objectives || [], assessment_type: metadata.assessment_type || '', suggested_packages: metadata.selected_packages || ['slide', 'lesson_plan'], status: 'ready_to_generate' }, // Preserve thoughtText if it was saved thoughtText: m.thoughtText || null }; } } /* DISABLED: Confirmed summary card in history - skipped to reduce user interaction cost const hasConfirmedSummary = filteredMessages.some(msg => msg.type === 'confirmed_summary'); const hasGatheredMsg = filteredMessages.some(msg => msg.content && msg.content.includes("I've gathered all the information") ); if (!hasConfirmedSummary) { const metadata = gen.blueprint.metadata || {}; const lessonInfo = { topic: metadata.topic || gen.blueprint.title || gen.title, grade: metadata.grade_level, subject: metadata.subject, duration: metadata.duration_minutes ? metadata.duration_minutes : null, teaching_style: metadata.teaching_style, assessment_type: metadata.assessment_type, objectives: metadata.objectives || metadata.learning_objectives || [], activities: metadata.activities || [] }; if (lessonInfo.topic || lessonInfo.grade || lessonInfo.subject) { if (!hasGatheredMsg) { messagesWithBlueprint.push({ role: 'assistant', content: "Great! I've gathered all the information. Please review and confirm the details below, or make any edits before generating your blueprint." }); } messagesWithBlueprint.push({ role: 'assistant', type: 'confirmed_summary', lessonInfo: lessonInfo, content: 'Lesson details confirmed' }); } } */ // Add package history as messages BEFORE current blueprint // Each history entry becomes: Blueprint (read-only) + Package pair var pkgHistory = gen.package_history || []; if (pkgHistory.length > 0) { // Sort by version ascending (oldest first) var sortedHistory = pkgHistory.slice().sort(function(a, b) { return (a.version || 0) - (b.version || 0); }); // Find the position of the first blueprint message in conversation // History should be inserted BEFORE it var insertIndex = -1; for (var fi = 0; fi < messagesWithBlueprint.length; fi++) { if (messagesWithBlueprint[fi].type === 'blueprint') { insertIndex = fi; break; } } // Build history messages array var historyMessages = []; for (var hi = 0; hi < sortedHistory.length; hi++) { var histEntry = sortedHistory[hi]; var histVersion = (typeof histEntry.version === 'number') ? histEntry.version : (hi + 1); var histTimestamp = histEntry.timestamp ? new Date(histEntry.timestamp).toLocaleString() : ''; var isHistZh = /[\u4e00-\u9fa5]/.test((histEntry.blueprint_snapshot && histEntry.blueprint_snapshot.title) || ''); // Add history blueprint (read-only) if (histEntry.blueprint_snapshot) { var histThoughtText = histEntry.blueprint_snapshot.thought_text || null; historyMessages.push({ id: 'hist-bp-' + histVersion, role: 'assistant', type: 'blueprint', blueprint: histEntry.blueprint_snapshot, content: 'Blueprint v' + histVersion, isReadOnly: true, isHistoryVersion: true, historyVersion: histVersion, historyTimestamp: histTimestamp, // Restore thought_text from saved blueprint snapshot thoughtText: histThoughtText }); // Add blueprint_ready message after blueprint (before package) historyMessages.push({ id: 'hist-bp-ready-' + histVersion, role: 'assistant', type: 'blueprint_ready', content: isHistZh ? 'Blueprint v' + histVersion + ' 已生成完成!' : 'Blueprint v' + histVersion + ' is ready!', isHistoryVersion: true, historyVersion: histVersion, materialsGenerated: true // Hide buttons for history }); } // Add history package if (histEntry.package) { historyMessages.push({ id: 'hist-pkg-' + histVersion, role: 'assistant', type: 'package', pkg: histEntry.package, content: 'Materials v' + histVersion, isHistoryVersion: true, historyVersion: histVersion, historyTimestamp: histTimestamp }); } } // Insert history messages before the current blueprint if (insertIndex >= 0 && historyMessages.length > 0) { // Splice history messages into the array before the current blueprint messagesWithBlueprint.splice.apply(messagesWithBlueprint, [insertIndex, 0].concat(historyMessages)); } else if (historyMessages.length > 0) { // No blueprint message found, append at end (fallback) historyMessages.forEach(function(hm) { messagesWithBlueprint.push(hm); }); } // Now add regeneration messages after history (they were separated earlier) // Find where to insert: after the last history package message if (regenMessages.length > 0) { // Find the last history package message index var lastHistPkgIdx = -1; for (var ri = 0; ri < messagesWithBlueprint.length; ri++) { if (messagesWithBlueprint[ri].isHistoryVersion && messagesWithBlueprint[ri].type === 'package') { lastHistPkgIdx = ri; } } if (lastHistPkgIdx >= 0) { // Insert regen messages after last history package messagesWithBlueprint.splice.apply(messagesWithBlueprint, [lastHistPkgIdx + 1, 0].concat(regenMessages)); } else { // Fallback: add at the end (before current blueprint will be added) regenMessages.forEach(function(rm) { messagesWithBlueprint.push(rm); }); } } else { // No saved regen messages - generate fallback ones var lastHistEntry = sortedHistory[sortedHistory.length - 1]; var lastHistBlueprint = lastHistEntry.blueprint_snapshot || {}; var currentBlueprint = gen.blueprint || {}; // Detect language var isZhLang = /[\u4e00-\u9fa5]/.test(currentBlueprint.title || gen.title || ''); // Detect changes between last history and current var changesList = []; if (lastHistBlueprint.title !== currentBlueprint.title) { changesList.push(isZhLang ? '标题' : 'Title'); } if (lastHistBlueprint.summary !== currentBlueprint.summary) { changesList.push(isZhLang ? '概述' : 'Summary'); } if (JSON.stringify(lastHistBlueprint.learning_objectives) !== JSON.stringify(currentBlueprint.learning_objectives)) { changesList.push(isZhLang ? '学习目标' : 'Learning Objectives'); } if (JSON.stringify(lastHistBlueprint.lesson_flow) !== JSON.stringify(currentBlueprint.lesson_flow)) { changesList.push(isZhLang ? '教学流程' : 'Lesson Flow'); } if (JSON.stringify(lastHistBlueprint.differentiation_strategies) !== JSON.stringify(currentBlueprint.differentiation_strategies)) { changesList.push(isZhLang ? '差异化策略' : 'Differentiation Strategies'); } if (lastHistBlueprint.raw_content !== currentBlueprint.raw_content) { if (changesList.length === 0) { changesList.push(isZhLang ? '课程内容' : 'Content'); } } var changesText = changesList.length > 0 ? changesList.join(', ') : (isZhLang ? '课程内容' : 'content'); var nextVersion = (lastHistEntry.version || 1) + 1; // Find last history package to insert after var lastHistPkgIdx2 = -1; for (var ri2 = 0; ri2 < messagesWithBlueprint.length; ri2++) { if (messagesWithBlueprint[ri2].isHistoryVersion && messagesWithBlueprint[ri2].type === 'package') { lastHistPkgIdx2 = ri2; } } var fallbackRegenMsgs = [ { id: 'regen-user-' + nextVersion, role: 'user', content: isZhLang ? '我已修改 Blueprint,请重新生成教学材料。' : 'I\'ve modified the Blueprint, please regenerate teaching materials.' }, { id: 'regen-ack-' + nextVersion, role: 'assistant', content: isZhLang ? '好的,我注意到您修改了 Blueprint 的 **' + changesText + '**。\n\n💭 我会根据更新后的内容重新生成教学材料,确保与新的课程设计保持一致。' : 'Got it! I noticed you\'ve modified the Blueprint\'s **' + changesText + '**.\n\n💭 I\'ll regenerate the teaching materials based on the updates to ensure alignment with your new lesson design.' } ]; if (lastHistPkgIdx2 >= 0) { messagesWithBlueprint.splice.apply(messagesWithBlueprint, [lastHistPkgIdx2 + 1, 0].concat(fallbackRegenMsgs)); } else { fallbackRegenMsgs.forEach(function(m) { messagesWithBlueprint.push(m); }); } } } // Add a simple transition message before current blueprint (replaces the removed confirmation card) const hasTransitionMsg = filteredMessages.some(msg => msg.content && (msg.content.includes("I've created your lesson blueprint") || msg.content.includes("Here's your lesson blueprint") || msg.content.includes("已生成") || msg.content.includes("蓝图")) ); // Only add transition message if there's no history (for fresh generations) if (!hasTransitionMsg && pkgHistory.length === 0) { // Detect conversation language by checking user messages const userMessages = filteredMessages.filter(m => m.role === 'user'); const lastUserMsg = userMessages.length > 0 ? userMessages[userMessages.length - 1].content : ''; const isChinese = /[\u4e00-\u9fa5]/.test(lastUserMsg); messagesWithBlueprint.push({ role: 'assistant', content: isChinese ? "已根据您提供的信息生成课程蓝图。您可以在下方预览和编辑,准备好后即可生成教学材料。" : "I've created your lesson blueprint based on the information provided. You can review and edit it below, then generate your teaching materials when ready." }); } // For regeneration scenario, we filtered out old blueprint/package messages // so always add the current version if (!hasBlueprintMsg || hasPackageHistory) { // Current version blueprint - with version label if has history var currentVersion = hasPackageHistory ? (gen.package && gen.package.version) || (pkgHistory.length + 1) : 1; // Check if current blueprint's thought_text is same as any history version // to avoid duplicate thinking display var currentThought = gen.blueprint && gen.blueprint.thought_text ? gen.blueprint.thought_text : null; var isDuplicateThought = false; if (currentThought && pkgHistory && pkgHistory.length > 0) { for (var hi = 0; hi < pkgHistory.length; hi++) { var histBp = pkgHistory[hi].blueprint_snapshot; if (histBp && histBp.thought_text === currentThought) { isDuplicateThought = true; break; } } } messagesWithBlueprint.push({ role: 'assistant', type: 'blueprint', blueprint: gen.blueprint, content: hasPackageHistory ? 'Blueprint v' + currentVersion : 'Blueprint generated', // Include generation info for history tracking generationId: gen.id, blueprintHistory: gen.blueprint_history || [], version: currentVersion, updatedAt: gen.updated_at, // Restore thought_text from saved blueprint for thinking display // Skip if duplicate of history version to avoid showing same thinking twice thoughtText: isDuplicateThought ? null : currentThought }); } // Add blueprint_ready message if no package yet (to show Create Materials button) if (!gen.package && !hasPackageHistory) { var isZhLang = /[\u4e00-\u9fa5]/.test(gen.blueprint.title || ''); messagesWithBlueprint.push({ role: 'assistant', type: 'blueprint_ready', content: isZhLang ? '您的课程蓝图已准备就绪!您可以继续生成教学材料,或者先编辑蓝图。' : 'Your lesson blueprint is ready! You can now generate teaching materials, or edit the blueprint first.' }); } setBlueprint(gen.blueprint); } // Add current package message if exists if (gen.package) { var hasPackageMsg = restoredConversation.some(function(msg) { return msg.type === 'package'; }); // For regeneration scenario, we filtered out old package messages if (!hasPackageMsg || hasPackageHistory) { var pkgVersion = (gen.package && gen.package.version) || 1; messagesWithBlueprint.push({ role: 'assistant', type: 'package', pkg: gen.package, content: hasPackageHistory ? 'Materials v' + pkgVersion : 'Teaching materials generated' }); } else if (!hasPackageHistory) { // Ensure existing package messages have pkg data filled in for (var pi = 0; pi < messagesWithBlueprint.length; pi++) { var pmsg = messagesWithBlueprint[pi]; if (pmsg.type === 'package' && !pmsg.pkg && !pmsg.isHistoryVersion) { messagesWithBlueprint[pi] = Object.assign({}, pmsg, { pkg: gen.package }); } } } setTeachingPackage(gen.package); // Save blueprint snapshot for change detection on regeneration if (gen.blueprint) { // Ensure blueprint has raw_content for future comparisons var blueprintSnapshot = JSON.parse(JSON.stringify(gen.blueprint)); if (!blueprintSnapshot.raw_content) { blueprintSnapshot.raw_content = blueprintToMarkdown(gen.blueprint); } setLastGeneratedBlueprint(blueprintSnapshot); } } else { // CRITICAL: Reset teachingPackage to null when loading a generation without package // This prevents state carryover from a previously viewed generation that had a package setTeachingPackage(null); setLastGeneratedBlueprint(null); } setMessages(messagesWithBlueprint); setView('chat'); } else { // Generation not found console.warn('Generation not found, redirecting to home'); navigateTo('/'); } } catch (e) { console.error('Failed to load generation:', e); // Clear URL and go home navigateTo('/'); } } else if (path === 'library') { setView('library'); } else if (path === 'chat') { setView('chat'); } else { setView('home'); } }; handleRoute(); window.addEventListener('popstate', handleRoute); return () => window.removeEventListener('popstate', handleRoute); }, [authChecked, currentUser]); // Load recent generations when user is authenticated useEffect(() => { if (authChecked && currentUser) { loadRecentGenerations(); } }, [authChecked, currentUser]); // Scroll to bottom of chat useEffect(() => { if (chatRef.current) { chatRef.current.scrollTop = chatRef.current.scrollHeight; } }, [messages]); // ============================================ // Auth Handlers // ============================================ const handleLogin = (user) => { setCurrentUser(user); }; const handleLogout = async () => { try { await api.logout(); } catch (e) { // Ignore logout errors } clearAuthToken(); setCurrentUser(null); setView('home'); setMessages([]); setBlueprint(null); setTeachingPackage(null); setCurrentGenerationId(null); setCurrentGeneration(null); // Navigate to home page and update URL window.history.pushState({}, '', '/#/'); }; // ============================================ // Conditional Renders (after all hooks) // ============================================ // Show loading while checking auth if (!authChecked) { return (
progress_activity

Loading...

); } // Show login page if not authenticated if (!currentUser) { return ; } // Handle file upload - uploads file and adds to pending attachments const handleFileUpload = async (event) => { const file = event.target.files[0]; if (!file) return; setIsUploading(true); const formData = new FormData(); formData.append('file', file); try { const response = await fetch('/api/v1/upload-file', { method: 'POST', headers: getAuthHeaders(), body: formData }); if (!response.ok) throw new Error('Upload failed'); const result = await response.json(); // Add to pending attachments (shown in input area, sent with next message) setPendingAttachments(prev => [...prev, { id: Date.now(), filename: result.filename, extracted_text: result.extracted_text, summary: result.summary, analysis_type: result.analysis_type, text_length: result.text_length || 0, file_url: result.file_url || null // URL to view/download the file }]); if (view === 'home') setView('chat'); } catch (error) { console.error('File upload error:', error); alert('Failed to upload file. Please try again.'); } finally { setIsUploading(false); } // Reset file input event.target.value = ''; }; // Remove a pending attachment const removePendingAttachment = (attachmentId) => { setPendingAttachments(prev => prev.filter(a => a.id !== attachmentId)); }; // Handle new chat const handleNewChat = () => { setView('home'); setMessages([]); setBlueprint(null); setTeachingPackage(null); setExtractedInfo(null); setCurrentGenerationId(null); setCurrentGeneration(null); setShowPackageInGeneration(false); setUploadedFiles([]); navigateTo('/'); }; // Handle send message const handleSend = async () => { // Allow sending with just attachments (no text) if ((!inputValue.trim() && pendingAttachments.length === 0) || isLoading) return; const userMessage = inputValue.trim(); const currentAttachments = [...pendingAttachments]; // Clear input and attachments setInputValue(''); setPendingAttachments([]); // Add attachments to persistent uploadedFiles for blueprint generation if (currentAttachments.length > 0) { setUploadedFiles(prev => [...prev, ...currentAttachments]); } // Build user message with attachment info for display var displayMessage = userMessage; if (currentAttachments.length > 0) { var attachmentInfo = currentAttachments.map(function(a) { return getFileIcon(a.analysis_type) + ' ' + a.filename; }).join(', '); displayMessage = currentAttachments.length > 0 && userMessage ? userMessage + '\n\n📎 Attached: ' + attachmentInfo : (userMessage || '📎 Attached: ' + attachmentInfo); } // Store attachments with URLs as part of the message object var messageAttachments = currentAttachments.map(function(a) { return { filename: a.filename, file_url: a.file_url, analysis_type: a.analysis_type }; }); setMessages(prev => [...prev, { role: 'user', content: displayMessage, attachments: messageAttachments }]); setIsLoading(true); if (view === 'home') setView('chat'); try { // Include user's message in history (not yet in messages state due to React batching) // Preserve type and attachments for proper message rendering after reload const history = [ ...messages.map(m => ({ role: m.role, content: m.content, type: m.type, attachments: m.attachments, thought_text: m.thoughtText })), { role: 'user', content: displayMessage } ]; // Combine all uploaded file content (including just-attached files) for blueprint detection var allFiles = [...uploadedFiles, ...currentAttachments]; var fileContent = null; if (allFiles.length > 0) { fileContent = allFiles.map(function(f) { return f.extracted_text || ''; }).join('\n\n---\n\n'); } // Check if user has existing materials (to detect feedback vs new request) var hasExistingMaterials = !!(teachingPackage && teachingPackage.files && teachingPackage.files.length > 0); // Use SSE streaming version for real-time progress feedback const result = await api.analyzeIntentStream( userMessage || 'Please analyze the attached files', history, fileContent, hasExistingMaterials, currentGenerationId, function(progress) { // Update AI progress state for UI feedback setAiProgress({ message: progress.message, step: progress.step, total_steps: progress.total_steps, is_ai_thinking: progress.is_ai_thinking || false, thought_text: progress.thought_text || '' }); } ); setAiProgress(null); // Clear progress when done // Handle feedback status - user is giving feedback or requesting regeneration if (result.status === 'feedback') { // Check if this is a regeneration request (user wants to regenerate package) var userMsgLower = userMessage.toLowerCase(); var isRegenerationRequest = ( userMsgLower.includes('重新生成') || userMsgLower.includes('regenerate') || userMsgLower.includes('重新做') || userMsgLower.includes('再生成') || (result.extracted_info && result.extracted_info.suggested_packages && result.extracted_info.suggested_packages.length > 0) ); // If we have a blueprint and this is a regeneration request, trigger package generation if (isRegenerationRequest && blueprint && currentGenerationId) { console.log('🔄 Auto-triggering package regeneration from feedback status'); setMessages(prev => [...prev, { role: 'assistant', content: result.message, thoughtText: result.thought_text || null }]); // Auto-trigger package regeneration with skipUserMessage=true // (user's original message is already in the chat) setTimeout(function() { handleCreateMaterials(true); }, 500); return; } // Otherwise just show the feedback message setMessages(prev => [...prev, { role: 'assistant', content: result.message, quickOptions: result.quick_options || [], thoughtText: result.thought_text || null }]); setView('chat'); return; } // Handle blueprint_provided status - user uploaded a blueprint file if (result.status === 'blueprint_provided') { setMessages(prev => [...prev, { role: 'assistant', content: result.message, thoughtText: result.thought_text || null }]); // Convert extracted blueprint to proper format var extractedBp = result.extracted_blueprint || {}; var blueprintResult = { title: extractedBp.title || 'Lesson Plan', summary: extractedBp.summary || '', metadata: { grade_level: extractedBp.grade_level || '', subject: extractedBp.subject || '', duration_minutes: extractedBp.duration_minutes || 45 }, learning_objectives: (extractedBp.learning_objectives || []).map(function(obj) { return typeof obj === 'string' ? { text: obj } : obj; }), lesson_flow: (extractedBp.lesson_flow || []).map(function(phase, idx) { return { title: phase.title || 'Phase ' + (idx + 1), duration_minutes: phase.duration_minutes || 10, description: phase.description || '', phase: phase.phase || '' }; }), standards: { primary_standards: extractedBp.standards || [] }, differentiation_strategies: extractedBp.differentiation_strategies || {} }; setBlueprint(blueprintResult); // Add blueprint as a message in the chat setMessages(prev => [...prev, { role: 'assistant', type: 'blueprint', blueprint: blueprintResult, content: 'Blueprint from your uploaded file' }]); // Clear uploaded files after using them setUploadedFiles([]); setView('chat'); return; } setExtractedInfo(result.extracted_info); setMessages(prev => [...prev, { role: 'assistant', content: result.message, quickOptions: result.quick_options || [], thoughtText: result.thought_text || null }]); // If ready to generate blueprint - show confirmation card for review if (result.status === 'ready_to_generate' && result.confidence >= 0.7) { const info = result.extracted_info; // Detect language for confirmation message var confirmCardMsg = getLocalizedMsg(messages, "Great! I've gathered all the information. Please review and confirm the details below, or make any edits before generating your blueprint.", "太棒了!我已收集所有信息。请在下方检查并确认详情,或在生成蓝图前进行编辑。" ); // Show confirmation card for user to review and select packages setMessages(prev => [...prev, { role: 'assistant', type: 'confirmation', content: confirmCardMsg, extractedInfo: info, thoughtText: result.thought_text || null }]); setView('chat'); } } catch (error) { console.error('Error:', error); var errorMsg = detectLanguage() === 'zh' ? "🔄 遇到临时问题,请重新发送您的消息或刷新页面后重试。" : "🔄 Encountered a temporary issue. Please resend your message or refresh the page and try again."; setMessages(prev => [...prev, { role: 'assistant', content: errorMsg }]); } finally { setIsLoading(false); } }; // Handle quick option click - sends the selected option as a message // option can be either { label, value } object or a string (for backward compatibility) const handleQuickOptionSend = async (option) => { if (isLoading) return; // Support both object { label, value } and string formats var displayText = typeof option === 'object' ? option.label : option; var valueText = typeof option === 'object' ? option.value : option; // Show the label (Chinese) to user, but send value to backend for processing setMessages(prev => [...prev, { role: 'user', content: displayText }]); setIsLoading(true); if (view === 'home') setView('chat'); try { // Include user's option in history (not yet in messages state due to React batching) // Preserve type and attachments for proper message rendering after reload // Use displayText (label) for history to maintain language consistency const history = [ ...messages.map(m => ({ role: m.role, content: m.content, type: m.type, attachments: m.attachments, thought_text: m.thoughtText })), { role: 'user', content: displayText } ]; // Check if user has existing materials var hasExistingMaterials = !!(teachingPackage && teachingPackage.files && teachingPackage.files.length > 0); // Send the value to backend for intent analysis (SSE streaming version) const result = await api.analyzeIntentStream( valueText, history, null, hasExistingMaterials, currentGenerationId, function(progress) { setAiProgress({ message: progress.message, step: progress.step, total_steps: progress.total_steps, is_ai_thinking: progress.is_ai_thinking || false, thought_text: progress.thought_text || '' }); } ); setAiProgress(null); // Clear progress when done // Handle feedback status - check if regeneration is requested if (result.status === 'feedback') { var valueLower = valueText.toLowerCase(); var isRegenRequest = ( valueLower.includes('重新生成') || valueLower.includes('regenerate') || valueLower.includes('重新做') || valueLower.includes('再生成') || (result.extracted_info && result.extracted_info.suggested_packages && result.extracted_info.suggested_packages.length > 0) ); // If we have a blueprint and this is a regeneration request, trigger package generation if (isRegenRequest && blueprint && currentGenerationId) { console.log('🔄 Auto-triggering package regeneration from feedback status (generation context)'); setMessages(prev => [...prev, { role: 'assistant', content: result.message, thoughtText: result.thought_text || null }]); // Auto-trigger package regeneration with skipUserMessage=true // (user's original message is already in the chat) setTimeout(function() { handleCreateMaterials(true); }, 500); return; } setMessages(prev => [...prev, { role: 'assistant', content: result.message, quickOptions: result.quick_options || [], thoughtText: result.thought_text || null }]); setView('chat'); return; } setExtractedInfo(result.extracted_info); setMessages(prev => [...prev, { role: 'assistant', content: result.message, quickOptions: result.quick_options || [], thoughtText: result.thought_text || null }]); // If ready to generate blueprint - show confirmation card for review if (result.status === 'ready_to_generate' && result.confidence >= 0.7) { const info = result.extracted_info; // Detect language for confirmation message var confirmCardMsgQuick = getLocalizedMsg(messages, "Great! I've gathered all the information. Please review and confirm the details below, or make any edits before generating your blueprint.", "太棒了!我已收集所有信息。请在下方检查并确认详情,或在生成蓝图前进行编辑。" ); // Show confirmation card for user to review and select packages setMessages(prev => [...prev, { role: 'assistant', type: 'confirmation', content: confirmCardMsgQuick, extractedInfo: info, thoughtText: result.thought_text || null }]); setView('chat'); } } catch (error) { console.error('Quick option error:', error); var errorMsg = detectLanguage() === 'zh' ? "🔄 遇到临时问题,请重新点击选项或输入您的回复。" : "🔄 Encountered a temporary issue. Please click the option again or type your response."; setMessages(prev => [...prev, { role: 'assistant', content: errorMsg }]); } finally { setIsLoading(false); } }; // Handle confirmed blueprint generation const handleConfirmGenerate = async (confirmedInfo) => { setIsLoading(true); setIsGeneratingBlueprint(true); // Detect language from topic var isZhTopic = confirmedInfo.topic && /[\u4e00-\u9fa5]/.test(confirmedInfo.topic); // Save the confirmed lesson info for display (including selected packages) setConfirmedLessonInfo(confirmedInfo); // Add a summary of confirmed info as user message var summaryParts = []; if (confirmedInfo.topic) summaryParts.push((isZhTopic ? '主题: ' : 'Topic: ') + confirmedInfo.topic); if (confirmedInfo.grade) summaryParts.push((isZhTopic ? '年级: ' : 'Grade: ') + confirmedInfo.grade); if (confirmedInfo.subject) summaryParts.push((isZhTopic ? '学科: ' : 'Subject: ') + confirmedInfo.subject); if (confirmedInfo.duration) summaryParts.push((isZhTopic ? '时长: ' : 'Duration: ') + confirmedInfo.duration + (isZhTopic ? ' 分钟' : ' min')); if (confirmedInfo.teaching_style) summaryParts.push((isZhTopic ? '教学风格: ' : 'Style: ') + confirmedInfo.teaching_style); // Add selected packages to summary if (confirmedInfo.selectedPackages && confirmedInfo.selectedPackages.length > 0) { var pkgLabels = { slide: isZhTopic ? '互动课件' : 'Slides', lesson_plan: isZhTopic ? '教案' : 'Lesson Plan', worksheet: isZhTopic ? '学生练习' : 'Worksheet', quiz: isZhTopic ? '测验' : 'Quiz', exit_ticket: isZhTopic ? '出门检测' : 'Exit Ticket', group_activity: isZhTopic ? '小组活动' : 'Group Activity', rubric: isZhTopic ? '评分标准' : 'Rubric', vocabulary_cards: isZhTopic ? '词汇卡片' : 'Vocabulary Cards' }; var selectedLabels = confirmedInfo.selectedPackages.map(function(p) { return pkgLabels[p] || p; }); summaryParts.push((isZhTopic ? '生成材料: ' : 'Materials: ') + selectedLabels.join(', ')); } // Detect language for user confirmation message var confirmMsgPrefix = isZhTopic ? "确认!按以下信息生成蓝图:" : "Confirmed! Generate blueprint with:"; setMessages(prev => [...prev, { role: 'user', content: confirmMsgPrefix + "\n" + summaryParts.join(' | ') }]); // Add a generating message var generatingMsgConfirm = isZhTopic ? "太棒了!正在为您生成课程蓝图..." : "Perfect! Generating your lesson blueprint now..."; setMessages(prev => [...prev, { role: 'assistant', content: generatingMsgConfirm }]); try { // Preserve type, attachments, and thought_text for proper message rendering after reload const fullConversation = messages.map(m => ({ role: m.role, content: m.content, type: m.type, attachments: m.attachments, thought_text: m.thoughtText })); // Combine confirmed additional_context with uploaded file content var confirmedCombinedCtx = confirmedInfo.additional_context || ''; if (uploadedFiles.length > 0) { var confirmedFileParts = uploadedFiles.map(function(f) { return '--- Uploaded Reference: ' + f.filename + ' ---\n' + (f.extracted_text || ''); }).join('\n\n'); confirmedCombinedCtx = confirmedCombinedCtx ? confirmedCombinedCtx + '\n\n' + confirmedFileParts : confirmedFileParts; } // Use SSE streaming version for real-time progress feedback const blueprintResult = await api.generateBlueprintStream({ topic: confirmedInfo.topic, grade: confirmedInfo.grade || '5', subject: confirmedInfo.subject || '', duration: parseInt(confirmedInfo.duration) || 45, objectives: confirmedInfo.objectives || [], special_requirements: confirmedInfo.special_requirements || [], additional_context: confirmedCombinedCtx, teaching_style: confirmedInfo.teaching_style || '', activities: confirmedInfo.activities || [], student_context: confirmedInfo.student_context || '', assessment_type: confirmedInfo.assessment_type || '', selected_packages: confirmedInfo.selectedPackages || ['slide', 'lesson_plan'] }, fullConversation, (function() { // Throttle partial_content updates to prevent UI freezing during streaming // Complex regex parsing in tryParseStreamingBlueprint can block the main thread var lastUpdateTime = 0; var pendingProgress = null; var throttleTimeout = null; var THROTTLE_INTERVAL = 300; // ms - update UI at most every 300ms return function(progress) { var now = Date.now(); var hasPartialContent = progress.partial_content && progress.partial_content.length > 0; // Always update immediately for non-writing progress (status messages) if (!progress.is_writing || !hasPartialContent) { setAiProgress({ message: progress.message, step: progress.step, total_steps: progress.total_steps, is_ai_thinking: progress.is_ai_thinking || false, thought_text: progress.thought_text || '', is_writing: progress.is_writing || false, partial_content: progress.partial_content || '' }); return; } // For streaming content, apply throttling pendingProgress = progress; // If enough time has passed, update immediately if (now - lastUpdateTime >= THROTTLE_INTERVAL) { lastUpdateTime = now; setAiProgress({ message: progress.message, step: progress.step, total_steps: progress.total_steps, is_ai_thinking: progress.is_ai_thinking || false, thought_text: progress.thought_text || '', is_writing: progress.is_writing || false, partial_content: progress.partial_content || '' }); } else if (!throttleTimeout) { // Schedule update for the end of throttle interval throttleTimeout = setTimeout(function() { throttleTimeout = null; lastUpdateTime = Date.now(); if (pendingProgress) { setAiProgress({ message: pendingProgress.message, step: pendingProgress.step, total_steps: pendingProgress.total_steps, is_ai_thinking: pendingProgress.is_ai_thinking || false, thought_text: pendingProgress.thought_text || '', is_writing: pendingProgress.is_writing || false, partial_content: pendingProgress.partial_content || '' }); } }, THROTTLE_INTERVAL - (now - lastUpdateTime)); } }; })()); setAiProgress(null); // Clear progress when done // Extract blueprint and generation_id from result var blueprintData = blueprintResult.blueprint; var newGenerationId = blueprintResult.generation_id; // Add selected packages to blueprint result blueprintData.selected_packages = confirmedInfo.selectedPackages || ['slide', 'lesson_plan']; setBlueprint(blueprintData); // CRITICAL: Immediately set the new generation ID to prevent overwriting old generations if (newGenerationId) { setCurrentGenerationId(newGenerationId); console.log('📝 New blueprint created with generation_id:', newGenerationId); } await loadRecentGenerations(); // Add blueprint as a message (with thought_text and generationId) setMessages(prev => [...prev, { role: 'assistant', type: 'blueprint', blueprint: blueprintData, content: 'Blueprint generated successfully', thoughtText: blueprintData.thought_text || null, generationId: newGenerationId // Include the new generation ID }]); // Add post-blueprint guidance message with quick options (localized) var blueprintReadyMsg = isZhTopic ? "Blueprint 已生成完成!您可以点击上方预览并编辑,或直接开始生成教学材料。" : "Blueprint ready! You can preview and edit above, or start generating teaching materials."; setMessages(prev => [...prev, { role: 'assistant', type: 'blueprint_ready', content: blueprintReadyMsg }]); // Clear uploaded files setUploadedFiles([]); // Navigate to the new generation view if (newGenerationId) { const newGen = await api.getGeneration(newGenerationId); if (newGen) { setCurrentGeneration(newGen); navigateTo(`/generation/${newGenerationId}`); } } setView('chat'); } catch (error) { console.error('Blueprint generation error:', error); var errorMsg = isZhTopic ? "🔄 生成蓝图时遇到临时问题,请点击确认卡片上的「✨ 生成蓝图」按钮重试。" : "🔄 Encountered an issue while generating the blueprint. Please click the '✨ Generate Blueprint' button on the confirmation card to retry."; setMessages(prev => [...prev, { role: 'assistant', content: errorMsg }]); } finally { setIsLoading(false); setIsGeneratingBlueprint(false); } }; // Handle blueprint save (from markdown edit) const handleBlueprintSave = (markdown) => { console.log('Blueprint markdown saved:', markdown); if (blueprint) { // Parse markdown to extract updated metadata const updatedBlueprint = { ...blueprint, raw_content: markdown }; // Extract title from first # heading const titleMatch = markdown.match(/^#\s+(.+)$/m); if (titleMatch) { updatedBlueprint.title = titleMatch[1].trim(); } // Extract metadata line: **Grade:** X | **Subject:** Y | **Duration:** Z minutes const metadataMatch = markdown.match(/\*\*Grade:\*\*\s*([^|]+)\s*\|\s*\*\*Subject:\*\*\s*([^|]+)\s*\|\s*\*\*Duration:\*\*\s*(\d+)\s*minutes/i); if (metadataMatch) { updatedBlueprint.metadata = { ...updatedBlueprint.metadata, grade_level: metadataMatch[1].trim(), subject: metadataMatch[2].trim(), duration_minutes: parseInt(metadataMatch[3], 10) }; } // Extract summary (text between title and metadata line) const summaryMatch = markdown.match(/^#\s+.+\n+(.+?)(?=\n+\*\*Grade:\*\*)/s); if (summaryMatch) { updatedBlueprint.summary = summaryMatch[1].trim(); } setBlueprint(updatedBlueprint); // Mark blueprint as modified since last material generation // This enables the Regenerate button to appear if (teachingPackage !== null) { setBlueprintModifiedSinceGeneration(true); } // Update the message in the messages array with incremented version to force re-render setMessages(prev => prev.map(msg => msg.type === 'blueprint' ? { ...msg, blueprint: updatedBlueprint, blueprintVersion: (msg.blueprintVersion || 0) + 1 } : msg )); } }; // Handle blueprint adjustment const handleAdjust = (sectionId) => { setAdjustSection(sectionId); setAdjustModalOpen(true); }; const handleAdjustSubmit = async (sectionId, instructions) => { setAdjustModalOpen(false); setIsLoading(true); try { const updatedBlueprint = await api.adjustBlueprint(blueprint, sectionId, instructions); setBlueprint(updatedBlueprint); // Update the blueprint message setMessages(prev => prev.map(msg => msg.type === 'blueprint' ? { ...msg, blueprint: updatedBlueprint } : msg )); } catch (error) { console.error('Error adjusting blueprint:', error); } finally { setIsLoading(false); } }; // Handle create materials - now shows progress in chat // skipUserMessage: if true, don't add a synthetic user message (used when triggered from chat) const handleCreateMaterials = async (skipUserMessage = false) => { // Set generating state to disable buttons setIsGeneratingMaterials(true); // Only show "Generating from Blueprint" context card on REGENERATION (not first generation) // On first generation, the blueprint is already visible in the chat above var isRegeneration = teachingPackage !== null; if (isRegeneration) { // Compare blueprints to find what changed var changes = []; // If we have a snapshot of the last generated blueprint, compare in detail if (lastGeneratedBlueprint) { var currentMeta = blueprint.metadata || {}; var oldMeta = lastGeneratedBlueprint.metadata || {}; // Check title change if (blueprint.title !== lastGeneratedBlueprint.title) { changes.push('标题'); } // Check metadata changes if (currentMeta.topic !== oldMeta.topic) changes.push('主题'); if (currentMeta.grade_level !== oldMeta.grade_level) changes.push('年级'); if (currentMeta.subject !== oldMeta.subject) changes.push('学科'); if (currentMeta.duration_minutes !== oldMeta.duration_minutes) changes.push('时长'); if (currentMeta.teaching_style !== oldMeta.teaching_style) changes.push('教学风格'); // Check learning objectives var currentObjs = JSON.stringify(blueprint.learning_objectives || []); var oldObjs = JSON.stringify(lastGeneratedBlueprint.learning_objectives || []); if (currentObjs !== oldObjs) changes.push('学习目标'); // Check lesson flow var currentFlow = JSON.stringify(blueprint.lesson_flow || []); var oldFlow = JSON.stringify(lastGeneratedBlueprint.lesson_flow || []); if (currentFlow !== oldFlow) changes.push('教学流程'); // Check assessments var currentAssess = JSON.stringify(blueprint.assessments || []); var oldAssess = JSON.stringify(lastGeneratedBlueprint.assessments || []); if (currentAssess !== oldAssess) changes.push('评估方式'); // Check raw content (markdown) and try to detect specific section changes var currentRaw = blueprint.raw_content || ''; var oldRaw = lastGeneratedBlueprint.raw_content || ''; if (currentRaw && currentRaw !== oldRaw) { // If old raw content doesn't exist, we can't compare sections // but we know something changed since raw_content was set if (!oldRaw) { if (changes.length === 0) { changes.push('课程内容细节'); } } else { // Extract sections from markdown to detect what changed var extractSections = function(md) { var sections = {}; var lines = md.split('\n'); var currentSection = ''; var content = []; for (var i = 0; i < lines.length; i++) { var line = lines[i]; var match = line.match(/^##\s+(.+)$/); if (match) { if (currentSection) { sections[currentSection] = content.join('\n'); } currentSection = match[1].trim(); content = []; } else if (currentSection) { content.push(line); } } if (currentSection) { sections[currentSection] = content.join('\n'); } return sections; }; var currentSections = extractSections(currentRaw); var oldSections = extractSections(oldRaw); var sectionChanges = []; // Check each section var allSectionNames = Object.keys(Object.assign({}, currentSections, oldSections)); for (var s = 0; s < allSectionNames.length; s++) { var sectionName = allSectionNames[s]; if (currentSections[sectionName] !== oldSections[sectionName]) { // Map English section names to Chinese var sectionNameMap = { 'Learning Objectives': '学习目标', 'Standards Alignment': '课程标准', 'Lesson Flow': '教学流程', 'Differentiation Strategies': '差异化策略', 'Assessment Strategies': '评估策略', 'Materials & Resources': '教学资源', 'Overview': '概述' }; var displayName = sectionNameMap[sectionName] || sectionName; if (changes.indexOf(displayName) === -1 && sectionChanges.indexOf(displayName) === -1) { sectionChanges.push(displayName); } } } // Add section changes that aren't already in changes for (var j = 0; j < sectionChanges.length; j++) { if (changes.indexOf(sectionChanges[j]) === -1) { changes.push(sectionChanges[j]); } } // If no specific sections detected but raw content changed if (changes.length === 0) { changes.push('课程内容细节'); } } } } else { // No snapshot available - use generic message (will be localized later) changes.push('content'); } // Detect language from conversation var userLang = (function() { var userMsgs = messages.filter(function(m) { return m.role === 'user'; }); var lastUserContent = userMsgs.length > 0 ? userMsgs[userMsgs.length - 1].content : ''; return isChinese(lastUserContent) ? 'zh' : 'en'; })(); // Change name translations var changeNameMap = { '标题': { en: 'title', zh: '标题' }, '主题': { en: 'topic', zh: '主题' }, '年级': { en: 'grade level', zh: '年级' }, '学科': { en: 'subject', zh: '学科' }, '时长': { en: 'duration', zh: '时长' }, '教学风格': { en: 'teaching style', zh: '教学风格' }, '学习目标': { en: 'learning objectives', zh: '学习目标' }, '教学流程': { en: 'lesson flow', zh: '教学流程' }, '评估方式': { en: 'assessments', zh: '评估方式' }, '课程标准': { en: 'standards alignment', zh: '课程标准' }, '差异化策略': { en: 'differentiation strategies', zh: '差异化策略' }, '评估策略': { en: 'assessment strategies', zh: '评估策略' }, '教学资源': { en: 'materials & resources', zh: '教学资源' }, '概述': { en: 'overview', zh: '概述' }, '课程内容细节': { en: 'content details', zh: '课程内容细节' }, '课程内容': { en: 'content', zh: '课程内容' }, 'content': { en: 'content', zh: '课程内容' } }; // Localize change names var localizedChanges = changes.map(function(c) { var mapped = changeNameMap[c]; return mapped ? mapped[userLang] : c; }); // Generate detailed change description var changeDesc = ''; var joinStr = userLang === 'zh' ? '、' : ', '; if (localizedChanges.length > 0) { changeDesc = '**' + localizedChanges.join(joinStr) + '**'; } else { changeDesc = userLang === 'zh' ? '**课程内容**' : '**content**'; } // Add user message indicating they want to regenerate with modified blueprint // Skip if triggered from chat (user message already exists) var userRegenMsg = skipUserMessage ? null : { id: Date.now() - 2, role: 'user', content: userLang === 'zh' ? '我已修改 Blueprint,请重新生成教学材料。' : 'I\'ve modified the Blueprint, please regenerate teaching materials.' }; // Generate detailed change diff for user feedback var detailedChanges = []; if (lastGeneratedBlueprint) { // Title change if (blueprint.title !== lastGeneratedBlueprint.title) { detailedChanges.push({ field: userLang === 'zh' ? '标题' : 'Title', from: lastGeneratedBlueprint.title || '', to: blueprint.title || '' }); } // Duration change var currentMeta = blueprint.metadata || {}; var oldMeta = lastGeneratedBlueprint.metadata || {}; if (currentMeta.duration_minutes !== oldMeta.duration_minutes) { detailedChanges.push({ field: userLang === 'zh' ? '时长' : 'Duration', from: (oldMeta.duration_minutes || 45) + (userLang === 'zh' ? ' 分钟' : ' min'), to: (currentMeta.duration_minutes || 45) + (userLang === 'zh' ? ' 分钟' : ' min') }); } // Grade change if (currentMeta.grade_level !== oldMeta.grade_level) { detailedChanges.push({ field: userLang === 'zh' ? '年级' : 'Grade', from: oldMeta.grade_level || '', to: currentMeta.grade_level || '' }); } // Learning objectives change var currentObjs = blueprint.learning_objectives || []; var oldObjs = lastGeneratedBlueprint.learning_objectives || []; if (JSON.stringify(currentObjs) !== JSON.stringify(oldObjs)) { var objDiff = { field: userLang === 'zh' ? '学习目标' : 'Learning Objectives', details: [] }; // Find added objectives currentObjs.forEach(function(obj, idx) { var objText = typeof obj === 'string' ? obj : (obj.text || obj.objective || ''); var oldObj = oldObjs[idx]; var oldText = oldObj ? (typeof oldObj === 'string' ? oldObj : (oldObj.text || oldObj.objective || '')) : null; if (!oldText) { objDiff.details.push((userLang === 'zh' ? '+ 新增: ' : '+ Added: ') + objText.substring(0, 50) + (objText.length > 50 ? '...' : '')); } else if (objText !== oldText) { objDiff.details.push((userLang === 'zh' ? '~ 修改: ' : '~ Changed: ') + objText.substring(0, 40) + '...'); } }); if (objDiff.details.length > 0) { detailedChanges.push(objDiff); } } // Lesson flow change var currentFlow = blueprint.lesson_flow || []; var oldFlow = lastGeneratedBlueprint.lesson_flow || []; if (JSON.stringify(currentFlow) !== JSON.stringify(oldFlow)) { var flowDiff = { field: userLang === 'zh' ? '教学流程' : 'Lesson Flow', details: [] }; currentFlow.forEach(function(phase, idx) { var oldPhase = oldFlow[idx]; if (!oldPhase) { flowDiff.details.push((userLang === 'zh' ? '+ 新增阶段: ' : '+ New phase: ') + (phase.title || phase.phase || '')); } else if (phase.title !== oldPhase.title || phase.duration_minutes !== oldPhase.duration_minutes) { flowDiff.details.push((userLang === 'zh' ? '~ 修改: ' : '~ Changed: ') + (phase.title || phase.phase || '') + (phase.duration_minutes !== oldPhase.duration_minutes ? ' (' + oldPhase.duration_minutes + '→' + phase.duration_minutes + (userLang === 'zh' ? '分钟)' : 'min)') : '')); } }); if (flowDiff.details.length > 0) { detailedChanges.push(flowDiff); } } } // Build detailed change description text var detailedChangeText = ''; if (detailedChanges.length > 0) { detailedChangeText = '\n\n📝 ' + (userLang === 'zh' ? '**具体修改内容:**' : '**Specific changes:**'); detailedChanges.forEach(function(change) { if (change.from !== undefined && change.to !== undefined) { detailedChangeText += '\n• **' + change.field + '**: `' + change.from + '` → `' + change.to + '`'; } else if (change.details && change.details.length > 0) { detailedChangeText += '\n• **' + change.field + '**:'; change.details.slice(0, 3).forEach(function(d) { detailedChangeText += '\n ' + d; }); if (change.details.length > 3) { detailedChangeText += '\n ' + (userLang === 'zh' ? '...还有 ' + (change.details.length - 3) + ' 项修改' : '...and ' + (change.details.length - 3) + ' more changes'); } } }); } // Generate thoughtful acknowledgment based on changes (localized) var thoughtProcess = ''; var hasObjectives = changes.includes('学习目标') || changes.includes('Learning Objectives'); var hasFlow = changes.includes('教学流程') || changes.includes('Lesson Flow'); var hasDuration = changes.includes('时长'); var hasGradeOrStyle = changes.includes('年级') || changes.includes('教学风格'); if (userLang === 'zh') { thoughtProcess = '\n\n🔄 **重新生成策略:**'; if (hasObjectives) { thoughtProcess += '\n• 学习目标调整 → 重新设计活动和评估方式'; } if (hasFlow) { thoughtProcess += '\n• 教学流程修改 → 更新课件结构和互动环节'; } if (hasDuration) { thoughtProcess += '\n• 时长变化 → 重新平衡各环节内容深度'; } if (hasGradeOrStyle) { thoughtProcess += '\n• 年级/风格变化 → 调整语言难度和互动形式'; } if (!hasObjectives && !hasFlow && !hasDuration && !hasGradeOrStyle) { thoughtProcess += '\n• 仔细检查所有修改,确保材料与 Blueprint 完全一致'; } } else { thoughtProcess = '\n\n🔄 **Regeneration strategy:**'; if (hasObjectives) { thoughtProcess += '\n• Learning objectives changed → Redesign activities and assessments'; } if (hasFlow) { thoughtProcess += '\n• Lesson flow modified → Update slide structure and interactions'; } if (hasDuration) { thoughtProcess += '\n• Duration changed → Rebalance content depth'; } if (hasGradeOrStyle) { thoughtProcess += '\n• Grade/style changed → Adjust language and interaction level'; } if (!hasObjectives && !hasFlow && !hasDuration && !hasGradeOrStyle) { thoughtProcess += '\n• Review all changes to ensure perfect alignment'; } } // Add Tess acknowledgment message with details var ackContent = userLang === 'zh' ? '好的,我注意到您修改了 Blueprint 的 ' + changeDesc + '。' + detailedChangeText + thoughtProcess + '\n\n⏳ 正在根据更新后的内容重新生成教学材料...' : 'Got it! I noticed you\'ve modified the Blueprint\'s ' + changeDesc + '.' + detailedChangeText + thoughtProcess + '\n\n⏳ Regenerating teaching materials based on the updates...'; var assistantAckMsg = { id: Date.now() - 1, role: 'assistant', content: ackContent }; // Build updated messages array with regeneration context var updatedMessages = messages.map(function(msg) { if (msg.type === 'blueprint') { return Object.assign({}, msg, { isReadOnly: true }); } return msg; }); // Add user message only if not triggered from chat (user message already exists) if (userRegenMsg) { updatedMessages.push(userRegenMsg); } updatedMessages.push(assistantAckMsg); setMessages(updatedMessages); // Small delay to let users see the acknowledgment await new Promise(resolve => setTimeout(resolve, 800)); // Show the current blueprint being used as context for regeneration var blueprintMsg = { id: Date.now(), role: 'assistant', type: 'blueprint', blueprint: blueprint, content: 'Blueprint being used for generation', isGenerationContext: true // Flag to indicate this is the generation context }; updatedMessages = updatedMessages.concat([blueprintMsg]); setMessages(updatedMessages); // Small delay to let users see the blueprint context await new Promise(resolve => setTimeout(resolve, 300)); } // Determine which messages to send to API // For regeneration, use updatedMessages (includes regen context) // For first generation, use original messages var conversationToSend = typeof updatedMessages !== 'undefined' ? updatedMessages : messages; // Add a generating message to the chat var generatingMsgId = Date.now(); // Get selected packages from blueprint for display var packagesToGenerate = blueprint.selected_packages || ['slide', 'lesson_plan', 'worksheet', 'quiz', 'exit_ticket']; setMessages(function(prev) { return prev.concat([{ id: generatingMsgId, role: 'assistant', type: 'generating', phase: 'Initializing...', progress: 5, content: 'Creating teaching materials...', selectedPackages: packagesToGenerate }]); }); // Scroll to bottom setTimeout(function() { if (chatRef.current) { chatRef.current.scrollTop = chatRef.current.scrollHeight; } }, 100); try { // If currentGenerationId exists, this is a regeneration - pass it to update existing generation var response = await api.generatePackage(blueprint, conversationToSend, currentGenerationId); var reader = response.body.getReader(); var decoder = new TextDecoder(); var currentPhase = 'Initializing...'; var currentProgress = 10; var currentDetailLog = ''; var activityLogs = []; // NEW: Track step status explicitly for reliable UI updates var completedSteps = []; var currentStep = null; var slidePreview = null; // For SSE slide preview // Throttle for UI updates to prevent lag during rapid SSE events var lastUIUpdateTime = 0; var pendingUIUpdate = null; var UI_THROTTLE_INTERVAL = 200; // ms - update UI at most every 200ms var updateGeneratingMessage = function() { // Update both messages array AND standalone state for GeneratingView setGeneratingPhase(currentPhase); setGeneratingProgress(currentProgress); // Also update generatingSteps for GeneratingView setGeneratingSteps({ completedSteps: completedSteps.slice(), currentStep: currentStep }); setMessages(function(prev) { return prev.map(function(msg) { if (msg.id === generatingMsgId) { return Object.assign({}, msg, { phase: currentPhase, progress: currentProgress, detailLog: currentDetailLog, activityLogs: activityLogs.slice(-12), completedSteps: completedSteps.slice(), currentStep: currentStep, slidePreview: slidePreview }); } return msg; }); }); }; var throttledUIUpdate = function(forceUpdate) { var now = Date.now(); if (forceUpdate || now - lastUIUpdateTime >= UI_THROTTLE_INTERVAL) { lastUIUpdateTime = now; if (pendingUIUpdate) { clearTimeout(pendingUIUpdate); pendingUIUpdate = null; } updateGeneratingMessage(); } else if (!pendingUIUpdate) { pendingUIUpdate = setTimeout(function() { pendingUIUpdate = null; lastUIUpdateTime = Date.now(); updateGeneratingMessage(); }, UI_THROTTLE_INTERVAL - (now - lastUIUpdateTime)); } }; while (true) { var result = await reader.read(); if (result.done) break; var text = decoder.decode(result.value); var lines = text.split('\n').filter(function(l) { return l.startsWith('data: '); }); for (var i = 0; i < lines.length; i++) { var line = lines[i]; try { var data = JSON.parse(line.slice(6)); if (data.type === 'start') { currentPhase = data.message; currentProgress = 10; activityLogs.push('▶ ' + data.message); } else if (data.type === 'keepalive') { // Keepalive to prevent proxy timeout, just update phase currentPhase = data.message || 'Still working...'; } else if (data.type === 'detail') { // Detail progress (per-slide updates) currentDetailLog = data.message; // CRITICAL: Also update phase so UI step status updates in real-time if (data.phase) { currentPhase = data.message; currentStep = data.phase; } // Add to activity log for visual feedback if (data.message.includes('✅') || data.message.includes('Image') || data.message.includes('background') || data.message.includes('interactive') || data.message.includes('🖼️') || data.message.includes('🎨') || data.message.includes('🎮')) { activityLogs.push(data.message); } // Update progress bar for detail events with sub_step info if (data.sub_step !== undefined && data.sub_total) { var subProgress = (data.sub_step / data.sub_total) * 30 + 10; currentProgress = Math.max(currentProgress, Math.min(subProgress, 40)); } } else if (data.type === 'slide_preview') { // NEW: Handle real-time slide preview slidePreview = { slide_count: data.slide_count, slides_preview: data.slides_preview, theme: data.theme }; activityLogs.push('📄 ' + data.message); } else if (data.type === 'progress') { currentPhase = data.message; currentDetailLog = ''; // Track current step explicitly if (data.phase) { currentStep = data.phase; } // Handle completion status if (data.completed && data.phase) { // Mark this step as completed - normalize phase name var completedPhase = data.phase.replace('_complete', ''); // Handle 'slides' -> 'slide' normalization if (completedPhase === 'slides') completedPhase = 'slide'; if (!completedSteps.includes(completedPhase)) { completedSteps.push(completedPhase); console.log('[Scaffo] Step completed:', completedPhase, 'All completed:', completedSteps); } // Clear current step when this phase is done if (currentStep === data.phase) { currentStep = null; } activityLogs.push('✅ ' + data.message.replace('Generating ', '').replace('...', '')); } else { activityLogs.push('▸ ' + data.message); } // Check for parallel generation signal if (data.message && data.message.includes('parallel')) { currentStep = 'parallel'; } if (data.step && data.total) { currentProgress = data.completed ? (data.step / data.total) * 90 + 10 : ((data.step - 1) / data.total) * 90 + 20; currentProgress = Math.min(currentProgress, 95); } else { currentProgress = Math.min(currentProgress + 15, 90); } } else if (data.type === 'complete') { // Replace generating message with package message setMessages(function(prev) { return prev.map(function(msg) { if (msg.id === generatingMsgId) { return { id: msg.id, role: 'assistant', type: 'package', pkg: data.package, content: 'Teaching materials generated' }; } return msg; }); }); setTeachingPackage(data.package); // Reset modification flag and save blueprint snapshot with raw_content for comparison setBlueprintModifiedSinceGeneration(false); // Ensure blueprint has raw_content for future comparisons var blueprintSnapshot = JSON.parse(JSON.stringify(blueprint)); if (!blueprintSnapshot.raw_content) { blueprintSnapshot.raw_content = blueprintToMarkdown(blueprint); } setLastGeneratedBlueprint(blueprintSnapshot); if (data.generation_id) { // Only navigate if this is a new generation (not regeneration) // For regeneration, currentGenerationId is already set if (!currentGenerationId || currentGenerationId !== data.generation_id) { setCurrentGenerationId(data.generation_id); navigateTo('/generation/' + data.generation_id); } } loadRecentGenerations(); setIsGeneratingMaterials(false); return; // Exit the function } else if (data.type === 'error') { console.error('Generation error:', data.message); // Remove generating message and show error setMessages(function(prev) { return prev.filter(function(msg) { return msg.id !== generatingMsgId; }).concat([{ role: 'assistant', content: 'Sorry, there was an error generating the materials. Please try again.' }]); }); setIsGeneratingMaterials(false); return; } // Update the generating message with progress (throttled to prevent UI lag) // Force immediate update for important events (completion, errors) var isImportantEvent = data.type === 'complete' || data.type === 'error' || data.completed; throttledUIUpdate(isImportantEvent); } catch (e) {} } } } catch (error) { console.error('Error generating package:', error); // Remove generating message and show error setMessages(function(prev) { return prev.filter(function(msg) { return msg.id !== generatingMsgId; }).concat([{ role: 'assistant', content: 'Sorry, there was an error generating the materials. Please try again.' }]); }); setIsGeneratingMaterials(false); } }; // Render view const renderContent = () => { switch (view) { case 'home': case 'chat': return (
{/* Chat Panel - full width */}
{messages.length === 0 ? (
waving_hand

{(() => { const hour = new Date().getHours(); const name = currentUser && currentUser.name ? currentUser.name : 'Teacher'; if (hour < 12) return `Good morning, ${name}.`; if (hour < 18) return `Good afternoon, ${name}.`; return `Good evening, ${name}.`; })()}

What are we planning today?

) : (
{messages.map((msg, i) => ( setBlueprintModifiedSinceGeneration(true)} isGenerationContext={msg.isGenerationContext} attachments={msg.attachments} isReadOnly={msg.isReadOnly} isHistoryVersion={msg.isHistoryVersion} historyVersion={msg.historyVersion} historyTimestamp={msg.historyTimestamp} thoughtText={msg.thoughtText} streamingContent={aiProgress && aiProgress.partial_content ? aiProgress.partial_content : null} isStreaming={aiProgress && aiProgress.is_writing && i === messages.length - 1} conversationMessages={messages} selectedPackages={msg.selectedPackages} completedSteps={msg.completedSteps} currentStep={msg.currentStep} slidePreview={msg.slidePreview} /> ))} {isLoading && (
dashboard_customize
Tess
{/* Show thinking during streaming (before response) */} {aiProgress && aiProgress.is_ai_thinking && aiProgress.thought_text && ( )} {/* Show streaming blueprint content - using StreamingBlueprintCard component for stable caching */} {aiProgress && aiProgress.is_writing && aiProgress.partial_content && ( )} {/* Progress indicator */}
{aiProgress ? (
{/* Header with icon and status */}
{aiProgress.is_ai_thinking ? ( ) : aiProgress.is_writing ? ( edit_note ) : aiProgress.message && aiProgress.message.includes('✅') ? ( ) : aiProgress.message && aiProgress.message.includes('✨') ? ( ) : ( progress_activity )} {aiProgress.message ? aiProgress.message.replace('🤖 ', '').replace('✅ ', '').replace('✨ ', '').replace('💭 ', '') : ''}
{/* Progress indicator */} {aiProgress.is_ai_thinking ? (
{aiProgress.thought_text ? aiProgress.thought_text.length : '0'} chars
) : aiProgress.is_writing && aiProgress.partial_content ? (
{aiProgress.partial_content.length} chars
{/* Live Blueprint Preview */}
{(() => { const parsed = tryParseStreamingBlueprint(aiProgress.partial_content); if (parsed.success && parsed.markdown) { const htmlContent = typeof marked !== 'undefined' ? marked.parse(parsed.markdown) : parsed.markdown; return React.createElement('div', { className: 'prose prose-sm dark:prose-invert max-w-none', dangerouslySetInnerHTML: { __html: htmlContent } }); } else { return React.createElement('pre', { className: 'text-xs text-gray-600 dark:text-gray-400 font-mono whitespace-pre-wrap break-words' }, aiProgress.partial_content.slice(-2000)); } })()}
) : aiProgress.step >= 4 && !aiProgress.is_ai_thinking ? (
Finalizing...
) : (
{aiProgress.step}/{aiProgress.total_steps}
)}
) : (
)}
)}
)}
{/* Input Area */}
{/* Pending Attachments Preview */} {pendingAttachments.length > 0 && (
{pendingAttachments.map(attachment => (
{getFileIcon(attachment.analysis_type)} {attachment.file_url ? ( e.stopPropagation()} > {attachment.filename} ) : ( {attachment.filename} )} {attachment.text_length > 0 ? `${Math.round(attachment.text_length / 1000)}k chars` : ''} {attachment.file_url && ( e.stopPropagation()} > open_in_new )}
))}
)}