/**
* 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 ? (
{(() => {
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 && (
{/* 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 ? (
) : (
{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
)}
removePendingAttachment(attachment.id)}
className="ml-1 text-gray-400 hover:text-red-500 transition-colors"
title="Remove attachment"
>
close
))}
)}
{/* Example Prompts - Only show on home */}
{messages.length === 0 && view === 'home' && (
Examples:
"Photosynthesis for 7th grade"
"American Revolution for 5th grade"
"Fractions for 4th grade"
)}
);
case 'generating':
return (
);
case 'generation':
return (
{
setAdjustSection(sectionId);
setAdjustModalOpen(true);
}}
/>
);
default:
return null;
}
};
// Handle selecting a generation from sidebar
const handleSelectGeneration = async (gen) => {
// Navigate to the generation - route handler will load the data
// User is identified via auth token, not URL (privacy)
navigateTo(`/generation/${gen.id}`);
};
// Handle deleting a generation
const handleDeleteGeneration = async (genId) => {
try {
// Optimistic update: immediately remove from UI
setRecentGenerations(prev => prev.filter(g => g.id !== genId));
// Call API to delete
await api.deleteGeneration(genId);
// Refresh list to ensure sync with server
await loadRecentGenerations();
// If viewing the deleted generation, go home
if (currentGenerationId === genId) {
handleNewChat();
}
} catch (error) {
console.error('Failed to delete generation:', error);
// Reload list to restore state on error
await loadRecentGenerations();
alert('Failed to delete generation. Please try again.');
}
};
// Handle selecting a template
const handleSelectTemplate = (template) => {
// Pre-fill the input with template-based prompt
setInputValue(`Create a lesson using the ${template.title} framework`);
navigateTo('/');
setView('home');
};
// Render library view
if (view === 'library') {
return (
navigateTo('/')}
onSelectTemplate={handleSelectTemplate}
/>
);
}
return (
{view !== 'generating' && view !== 'package' && (
navigateTo('/library')}
recentGenerations={recentGenerations}
onSelectGeneration={handleSelectGeneration}
onDeleteGeneration={handleDeleteGeneration}
currentGenerationId={currentGenerationId}
currentUser={currentUser}
onLogout={handleLogout}
/>
)}
{renderContent()}
setAdjustModalOpen(false)}
onSubmit={handleAdjustSubmit}
sectionId={adjustSection}
/>
);
};
// Render
var root = createRoot(document.getElementById('root'));
root.render( );