| (function () { |
| if (window.__EPIC_ERRANDS_V2_APP_STARTED__) return; |
| window.__EPIC_ERRANDS_V2_APP_STARTED__ = true; |
|
|
| const ASSET_BASE = window.EPIC_ASSET_BASE || "assets/"; |
| const ASSET_MANIFEST = window.EPIC_ASSET_MANIFEST || {}; |
| const API_BASE = window.EPIC_API_BASE || ""; |
| const EMBEDDED_SPACE_MODE = Boolean(window.EPIC_EMBEDDED_SPACE_MODE); |
| const GRADIO_API_PREFIX = window.EPIC_GRADIO_API_PREFIX || "/gradio_api"; |
| const root = document.getElementById("app"); |
|
|
| const themeToToken = { |
| classroom: "storybook", |
| questbook: "atelier", |
| comic: "comic", |
| }; |
|
|
| const themes = { |
| classroom: { |
| label: "Classroom", |
| token: "storybook", |
| parentTitle: "Classroom Captain", |
| kidTitle: "Star Student", |
| goalNoun: "Quest", |
| rewardNoun: "Badge", |
| waitingCopy: "Waiting for Classroom Captain approval.", |
| }, |
| questbook: { |
| label: "Questbook", |
| token: "atelier", |
| parentTitle: "Quest Keeper", |
| kidTitle: "Young Adventurer", |
| goalNoun: "Quest", |
| rewardNoun: "Crest", |
| waitingCopy: "Waiting for Quest Keeper approval.", |
| }, |
| comic: { |
| label: "Comic", |
| token: "comic", |
| parentTitle: "Comic Editor", |
| kidTitle: "Panel Hero", |
| goalNoun: "Mission", |
| rewardNoun: "Starburst", |
| waitingCopy: "Waiting for Comic Editor approval.", |
| }, |
| }; |
|
|
| const assets = { |
| "clean-room": { |
| questbook: { |
| image: "generated-v2/clean-room-questbook-d35e6ee10c.png", |
| audio: "generated-v2/clean-room-questbook-01493f395c.wav", |
| }, |
| classroom: { |
| image: "generated-v2/clean-room-classroom-c5cb506427.png", |
| audio: "generated-v2/clean-room-classroom-cf6f43d6ce.wav", |
| }, |
| comic: { |
| image: "generated-v2/clean-room-comic-9bce2b2b21.png", |
| audio: "generated-v2/clean-room-comic-88b770a8dc.wav", |
| }, |
| }, |
| "project-outline": { |
| questbook: { |
| image: "generated-v2/project-outline-questbook-e1d5ced2e8.png", |
| audio: "generated-v2/project-outline-questbook-bddf03af1a.wav", |
| }, |
| classroom: { |
| image: "generated-v2/project-outline-classroom-3783f10bae.png", |
| audio: "generated-v2/project-outline-classroom-70a886be5a.wav", |
| }, |
| comic: { |
| image: "generated-v2/project-outline-comic-4f9eaa443a.png", |
| audio: "generated-v2/project-outline-comic-ef3f180846.wav", |
| }, |
| }, |
| "read-20": { |
| questbook: { |
| image: "generated-v2/read-20-questbook-39aac8ba25.png", |
| audio: "generated-v2/read-20-questbook-5805518930.wav", |
| }, |
| classroom: { |
| image: "generated-v2/read-20-classroom-7c13f43782.png", |
| audio: "generated-v2/read-20-classroom-cca67b8288.wav", |
| }, |
| comic: { |
| image: "generated-v2/read-20-comic-b5ae2d561a.png", |
| audio: "generated-v2/read-20-comic-7ec8c91bf8.wav", |
| }, |
| }, |
| }; |
|
|
| const addElementsBaseThemeImages = { |
| classroom: "base-theme-images/classroom-base-theme-1024.png", |
| questbook: "base-theme-images/questbook-base-theme-1024.png", |
| comic: "base-theme-images/comic-base-theme-1024.png", |
| }; |
|
|
| const addElementsPhase1Variants = { |
| "clean-room": { |
| classroom: { |
| image: "add-elements/phase1-classroom.png", |
| source_goal: "Clean up my room before dinner", |
| }, |
| }, |
| "project-outline": { |
| questbook: { |
| image: "add-elements/phase1-questbook.png", |
| source_goal: "Finish my class project outline", |
| }, |
| }, |
| "read-20": { |
| comic: { |
| image: "add-elements/phase1-comic.png", |
| source_goal: "Read for 20 minutes", |
| }, |
| }, |
| }; |
|
|
| const addElementsPhase2Variants = { |
| "clean-room": { |
| classroom: { |
| image: "add-elements/phase2-classroom.png", |
| source_goal: "Clean up my room before dinner", |
| parent_reference_id: "parent-mom-demo", |
| child_reference_id: "child-boy-demo", |
| }, |
| }, |
| "project-outline": { |
| questbook: { |
| image: "add-elements/phase2-questbook.png", |
| source_goal: "Finish my class project outline", |
| parent_reference_id: "parent-dad-demo", |
| child_reference_id: "child-girl-demo", |
| }, |
| }, |
| "read-20": { |
| comic: { |
| image: "add-elements/phase2-comic.png", |
| source_goal: "Read for 20 minutes", |
| parent_reference_id: "parent-dad-demo", |
| child_reference_id: "child-boy-demo", |
| }, |
| }, |
| }; |
|
|
| const seededUploads = { |
| parent_photo_refs: [ |
| { |
| id: "parent-dad-demo", |
| kind: "parent_photo", |
| asset_ref: "Dad reference portrait", |
| preview_ref: "reference-seeds/parent-reference-photo.png", |
| privacy_scope: "session_only", |
| created_at: "local-demo", |
| }, |
| { |
| id: "parent-mom-demo", |
| kind: "parent_photo", |
| asset_ref: "Mom reference portrait", |
| preview_ref: "reference-seeds/placeholder-female-parent-720.png", |
| privacy_scope: "session_only", |
| created_at: "local-demo", |
| }, |
| ], |
| child_photo_refs: [ |
| { |
| id: "child-boy-demo", |
| kind: "child_photo", |
| asset_ref: "Boy reference portrait", |
| preview_ref: "reference-seeds/kid-reference-photo.png", |
| privacy_scope: "session_only", |
| created_at: "local-demo", |
| }, |
| { |
| id: "child-girl-demo", |
| kind: "child_photo", |
| asset_ref: "Girl reference portrait", |
| preview_ref: "reference-seeds/placeholder-girl-720.png", |
| privacy_scope: "session_only", |
| created_at: "local-demo", |
| }, |
| ], |
| custom_image_reference_refs: [], |
| parent_reference_audio_ref: { |
| id: "parent-audio-demo", |
| kind: "parent_reference_audio", |
| asset_ref: "Parent reference audio sample", |
| preview_ref: "reference-seeds/parent-reference-audio.m4a", |
| privacy_scope: "session_only", |
| created_at: "local-demo", |
| }, |
| }; |
|
|
| const defaultGenerationReferenceIds = ["parent-dad-demo", "child-girl-demo"]; |
|
|
| const seedGoalSpecs = [ |
| { |
| id: "goal-seed-clean-room", |
| ordinary_goal: "Clean up my room before dinner", |
| theme_id_at_creation: "questbook", |
| selected_generation_reference_ids: defaultGenerationReferenceIds, |
| audio_used_parent_reference: true, |
| kid_completion_state: "completed", |
| parent_reward_state: "waiting_for_approval", |
| }, |
| { |
| id: "goal-seed-project-outline", |
| ordinary_goal: "Finish my class project outline", |
| theme_id_at_creation: "questbook", |
| selected_generation_reference_ids: defaultGenerationReferenceIds, |
| audio_used_parent_reference: true, |
| kid_completion_state: "not_started", |
| parent_reward_state: "not_reviewed", |
| }, |
| { |
| id: "goal-seed-read-20", |
| ordinary_goal: "Read for 20 minutes", |
| theme_id_at_creation: "questbook", |
| selected_generation_reference_ids: defaultGenerationReferenceIds, |
| audio_used_parent_reference: true, |
| kid_completion_state: "not_started", |
| parent_reward_state: "not_reviewed", |
| }, |
| ]; |
|
|
| const qualityMode = { |
| id: "quality", |
| label: "Quality", |
| status: "enabled", |
| text_model_id: "nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF", |
| text_runtime: "llama.cpp", |
| text_format: "GGUF", |
| text_quantization: "Q4_K_M", |
| image_model_id: "black-forest-labs/FLUX.2-klein-9B", |
| image_runtime: "Diffusers", |
| image_format: "safetensors", |
| image_precision: "bf16", |
| image_output_size_px: 1024, |
| audio_model_id: "openbmb/VoxCPM2", |
| audio_runtime: "voxcpm", |
| audio_format: "safetensors", |
| audio_precision: "bf16", |
| }; |
|
|
| const speedMode = { |
| id: "speed", |
| label: "Speed", |
| status: "disabled_planned", |
| image_output_size_px: 720, |
| }; |
|
|
| const icons = { |
| home: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 10.5 12 4l8 6.5V20a1 1 0 0 1-1 1h-5v-6h-4v6H5a1 1 0 0 1-1-1v-9.5Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>', |
| wand: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="m14 4 6 6M3 21 17 7l-3-3L3 15v6Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M5 3v3M3.5 4.5h3M20 16v3M18.5 17.5h3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>', |
| kid: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 12a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM5.5 20c.7-3.8 2.9-5.8 6.5-5.8s5.8 2 6.5 5.8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>', |
| settings: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 15.2a3.2 3.2 0 1 0 0-6.4 3.2 3.2 0 0 0 0 6.4Z" stroke="currentColor" stroke-width="2"/><path d="M19 12a7.1 7.1 0 0 0-.1-1l2-1.5-2-3.5-2.4 1a7 7 0 0 0-1.7-1L14.5 3h-5l-.3 3a7 7 0 0 0-1.7 1L5.1 6l-2 3.5 2 1.5a7.1 7.1 0 0 0 0 2l-2 1.5 2 3.5 2.4-1a7 7 0 0 0 1.7 1l.3 3h5l.3-3a7 7 0 0 0 1.7-1l2.4 1 2-3.5-2-1.5c.1-.3.1-.7.1-1Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>', |
| lab: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M9 3h6M10 3v5l-5 9a3 3 0 0 0 2.6 4.5h8.8A3 3 0 0 0 19 17l-5-9V3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.5 16h9" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>', |
| check: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="m5 12.5 4.2 4L19 7" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg>', |
| x: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M7 7l10 10M17 7 7 17" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>', |
| sound: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 10v4h4l5 4V6l-5 4H4Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M16 9.5c.8.7 1.2 1.5 1.2 2.5s-.4 1.8-1.2 2.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>', |
| play: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M8 5.5v13l11-6.5-11-6.5Z" fill="currentColor"/></svg>', |
| pause: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M7 5h3v14H7V5Zm7 0h3v14h-3V5Z" fill="currentColor"/></svg>', |
| plus: '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>', |
| }; |
|
|
| let state = makeFallbackState(); |
|
|
| function makeFallbackState() { |
| const seedGoals = seedGoalSpecs.map((spec) => { |
| const goal = createGoal( |
| spec.ordinary_goal, |
| spec.theme_id_at_creation, |
| spec.id, |
| spec.selected_generation_reference_ids, |
| spec.audio_used_parent_reference |
| ); |
| goal.review_state = "accepted"; |
| goal.kid_completion_state = spec.kid_completion_state; |
| goal.parent_reward_state = spec.parent_reward_state; |
| return goal; |
| }); |
| const fallback = { |
| active_tab: "home", |
| active_theme_id: "questbook", |
| generation_mode: "quality", |
| available_generation_modes: [qualityMode, speedMode], |
| parent_profile: { |
| display_name: "Parent", |
| photo_refs: seededUploads.parent_photo_refs, |
| reference_audio_ref: seededUploads.parent_reference_audio_ref, |
| reference_audio_optional: true, |
| }, |
| children: [{ id: "child-1", display_name: "Kid", photo_refs: seededUploads.child_photo_refs }], |
| custom_image_reference_refs: [], |
| uploads: { |
| parent_photo_refs: seededUploads.parent_photo_refs, |
| child_photo_refs: seededUploads.child_photo_refs, |
| custom_image_reference_refs: [], |
| parent_reference_audio_ref: seededUploads.parent_reference_audio_ref, |
| }, |
| generation_references: [], |
| goal_draft: { |
| ordinary_goal: "Finish my class project outline", |
| selected_generation_reference_ids: defaultGenerationReferenceIds, |
| generation_mode: "quality", |
| }, |
| pending_review_goal: null, |
| kid_modal_goal_id: null, |
| goals: seedGoals, |
| accepted_goals: seedGoals, |
| selected_goal_id: seedGoals[0].id, |
| diy: buildLocalDiyState("questbook", seedGoals[0].ordinary_goal, defaultGenerationReferenceIds), |
| generation_status: { |
| state: "ready", |
| message: "Hosted deterministic generation is ready.", |
| backend_transport: "browser_fallback", |
| fallback_used: true, |
| }, |
| }; |
| syncGenerationReferences(fallback); |
| return fallback; |
| } |
|
|
| function asset(path) { |
| if (/^(data:|https?:|blob:)/.test(String(path || ""))) return path; |
| if (ASSET_MANIFEST[path]) return ASSET_MANIFEST[path]; |
| return `${ASSET_BASE}${path}`; |
| } |
|
|
| function escapeHtml(value) { |
| return String(value) |
| .replace(/&/g, "&") |
| .replace(/</g, "<") |
| .replace(/>/g, ">") |
| .replace(/"/g, """) |
| .replace(/'/g, "'"); |
| } |
|
|
| function theme() { |
| return themes[state.active_theme_id] || themes.questbook; |
| } |
|
|
| function assetKey(goalText) { |
| const text = String(goalText || "").toLowerCase(); |
| if (text.includes("read")) return "read-20"; |
| if (text.includes("project") || text.includes("outline")) return "project-outline"; |
| return "clean-room"; |
| } |
|
|
| function referenceKind(refId) { |
| const lowered = String(refId || "").toLowerCase(); |
| if (["parent", "dad", "mom"].some((token) => lowered.includes(token))) return "parent_photo"; |
| if (["child", "kid", "boy", "girl"].some((token) => lowered.includes(token))) return "child_photo"; |
| return "custom_image_reference"; |
| } |
|
|
| function hasParentChildRefs(referenceIds) { |
| const kinds = new Set((referenceIds || []).map(referenceKind)); |
| return kinds.has("parent_photo") && kinds.has("child_photo"); |
| } |
|
|
| function mediaAssetsFor(key, themeId, referenceIds) { |
| const variant = { ...(assets[key][themeId] || assets[key].questbook) }; |
| const imageProvenance = { |
| image_fallback_source: "generated_v2_fixture", |
| add_elements_cache_hit: false, |
| add_elements_contract: "not_applied", |
| base_theme_image_ref: addElementsBaseThemeImages[themeId], |
| }; |
| if (hasParentChildRefs(referenceIds)) { |
| const addElements = addElementsPhase2Variants[key]?.[themeId]; |
| if (addElements) { |
| variant.image = addElements.image; |
| Object.assign(imageProvenance, { |
| image_fallback_source: "cached_flux2_add_elements_phase2", |
| add_elements_cache_hit: true, |
| add_elements_phase: "phase2", |
| add_elements_contract: "base_theme_plus_parent_child_goal", |
| add_elements_source_goal: addElements.source_goal, |
| add_elements_reference_ids: [addElements.parent_reference_id, addElements.child_reference_id], |
| }); |
| } else { |
| Object.assign(imageProvenance, { |
| add_elements_contract: "base_theme_plus_parent_child_goal", |
| add_elements_cache_miss_reason: "no_exact_cached_phase2_theme_goal_match", |
| }); |
| } |
| return { variant, imageProvenance }; |
| } |
| if (!(referenceIds || []).length) { |
| const addElements = addElementsPhase1Variants[key]?.[themeId]; |
| if (addElements) { |
| variant.image = addElements.image; |
| Object.assign(imageProvenance, { |
| image_fallback_source: "cached_flux2_add_elements_phase1", |
| add_elements_cache_hit: true, |
| add_elements_phase: "phase1", |
| add_elements_contract: "base_theme_plus_goal_no_people", |
| add_elements_source_goal: addElements.source_goal, |
| }); |
| } |
| } |
| return { variant, imageProvenance }; |
| } |
|
|
| function titleFor(goalText, themeId) { |
| if (themeId === "comic") return "Mission: Everyday Hero"; |
| if (themeId === "classroom") return "Quest: Ready to Shine"; |
| return "Quest: Small Brave Step"; |
| } |
|
|
| function rewardFor(themeId) { |
| if (themeId === "comic") return "Starburst"; |
| if (themeId === "classroom") return "Badge"; |
| return "Crest"; |
| } |
|
|
| function narrationFor(goalText, themeId) { |
| if (themeId === "comic") return `Zoom in on the next win: ${goalText}. Finish it, then call in your victory.`; |
| if (themeId === "classroom") return `Take one clear school-day step and finish: ${goalText}.`; |
| return `The questbook opens to today's errand: ${goalText}.`; |
| } |
|
|
| function createGoal(ordinaryGoal, themeId, id, referenceIds, audioUsedParentReference) { |
| const normalizedTheme = themes[themeId] ? themeId : "questbook"; |
| const key = assetKey(ordinaryGoal); |
| const selectedRefs = referenceIds || []; |
| const { variant, imageProvenance } = mediaAssetsFor(key, normalizedTheme, selectedRefs); |
| const referenceSignature = (referenceIds || []).join(","); |
| return { |
| id: id || `goal-${Date.now()}`, |
| ordinary_goal: ordinaryGoal, |
| generation_mode: "quality", |
| theme_id_at_creation: normalizedTheme, |
| selected_generation_reference_ids: selectedRefs, |
| generated_title: titleFor(ordinaryGoal, normalizedTheme), |
| generated_narration: narrationFor(ordinaryGoal, normalizedTheme), |
| generated_reward_label: rewardFor(normalizedTheme), |
| overlay_text: { |
| text: ordinaryGoal, |
| theme_style_id: normalizedTheme, |
| position: "bottom", |
| max_lines: 3, |
| is_app_owned: true, |
| mutates_image_pixels: false, |
| mutates_audio: false, |
| }, |
| media: { |
| image_asset_ref: variant.image, |
| image_output_size_px: 1024, |
| image_mutable_from_review: false, |
| audio_asset_ref: variant.audio, |
| audio_enabled: true, |
| audio_used_parent_reference: Boolean(audioUsedParentReference), |
| audio_mutable_from_review: false, |
| }, |
| provenance: { |
| text_model_id: qualityMode.text_model_id, |
| text_runtime: qualityMode.text_runtime, |
| text_format: qualityMode.text_format, |
| text_quantization: qualityMode.text_quantization, |
| image_model_id: qualityMode.image_model_id, |
| image_runtime: qualityMode.image_runtime, |
| image_format: qualityMode.image_format, |
| image_precision: qualityMode.image_precision, |
| audio_model_id: qualityMode.audio_model_id, |
| audio_runtime: qualityMode.audio_runtime, |
| audio_format: qualityMode.audio_format, |
| audio_precision: qualityMode.audio_precision, |
| fallback_used: true, |
| trace_id: `local-v2-${key}-${normalizedTheme}-quality-media-${referenceSignature || "no-refs"}`, |
| ...imageProvenance, |
| }, |
| review_state: "pending", |
| kid_completion_state: "not_started", |
| parent_reward_state: "not_reviewed", |
| }; |
| } |
|
|
| function buildTrace(goal) { |
| return { |
| pipeline: ["plain_goal", "text_step", "image_step", "audio_step", "composed_card"], |
| plain_goal: goal.ordinary_goal, |
| selected_theme_id: goal.theme_id_at_creation, |
| selected_generation_reference_ids: goal.selected_generation_reference_ids, |
| generation_mode: "quality", |
| text_step: { |
| model: qualityMode.text_model_id, |
| runtime: qualityMode.text_runtime, |
| fallback_used: true, |
| }, |
| image_step: { |
| model: qualityMode.image_model_id, |
| runtime: qualityMode.image_runtime, |
| output_size_px: qualityMode.image_output_size_px, |
| result_asset_ref: goal.media.image_asset_ref, |
| image_fallback_source: goal.provenance.image_fallback_source, |
| add_elements_cache_hit: goal.provenance.add_elements_cache_hit, |
| add_elements_contract: goal.provenance.add_elements_contract, |
| base_theme_image_ref: goal.provenance.base_theme_image_ref, |
| }, |
| audio_step: { |
| model: qualityMode.audio_model_id, |
| runtime: qualityMode.audio_runtime, |
| result_asset_ref: goal.media.audio_asset_ref, |
| }, |
| composed_card_step: { |
| overlay_text: goal.overlay_text.text, |
| mutates_image_pixels: false, |
| }, |
| quality_model_contract: qualityMode, |
| image_output_size_px: 1024, |
| fallback_used: true, |
| save_to_app_status: "coming_soon", |
| }; |
| } |
|
|
| function buildLocalDiyState(themeId, ordinaryGoal, referenceIds) { |
| const selectedRefs = referenceIds && referenceIds.length |
| ? referenceIds |
| : defaultGenerationReferenceIds; |
| const preview = createGoal(ordinaryGoal, themeId, "diy-preview", selectedRefs); |
| return { |
| isolated_surface: true, |
| workflow_draft: { |
| ordinary_goal: ordinaryGoal, |
| selected_theme_id: themes[themeId] ? themeId : "questbook", |
| selected_generation_reference_ids: selectedRefs, |
| text_step: { model: qualityMode.text_model_id, runtime: qualityMode.text_runtime }, |
| image_step: { model: qualityMode.image_model_id, output_size_px: qualityMode.image_output_size_px }, |
| audio_step: { model: qualityMode.audio_model_id, runtime: qualityMode.audio_runtime }, |
| composed_card_step: { overlay_text: preview.overlay_text.text }, |
| }, |
| preview, |
| trace_json: buildTrace(preview), |
| save_to_app_status: "coming_soon", |
| }; |
| } |
|
|
| function normalizeGoal(goal) { |
| return { |
| ...goal, |
| selected_generation_reference_ids: goal.selected_generation_reference_ids || [], |
| overlay_text: { |
| text: goal.overlay_text?.text || goal.ordinary_goal || "", |
| theme_style_id: goal.overlay_text?.theme_style_id || goal.theme_id_at_creation || "questbook", |
| position: goal.overlay_text?.position || "bottom", |
| max_lines: goal.overlay_text?.max_lines || 3, |
| is_app_owned: true, |
| mutates_image_pixels: false, |
| mutates_audio: false, |
| }, |
| media: { |
| image_asset_ref: goal.media?.image_asset_ref || assets["clean-room"].questbook.image, |
| image_output_size_px: goal.media?.image_output_size_px || 1024, |
| image_mutable_from_review: false, |
| audio_asset_ref: goal.media?.audio_asset_ref || assets["clean-room"].questbook.audio, |
| audio_enabled: true, |
| audio_used_parent_reference: Boolean(goal.media?.audio_used_parent_reference), |
| audio_mutable_from_review: false, |
| }, |
| provenance: goal.provenance || { ...qualityMode, fallback_used: true }, |
| review_state: goal.review_state || "pending", |
| kid_completion_state: goal.kid_completion_state || "not_started", |
| parent_reward_state: goal.parent_reward_state || "not_reviewed", |
| }; |
| } |
|
|
| function syncStateAliases(targetState = state) { |
| const parent = targetState.parent_profile || {}; |
| const child = (targetState.children || [])[0] || { photo_refs: [] }; |
| const existingUploads = targetState.uploads || {}; |
| const hasAudioUpload = Object.prototype.hasOwnProperty.call(existingUploads, "parent_reference_audio_ref"); |
| targetState.uploads = { |
| parent_photo_refs: existingUploads.parent_photo_refs?.length ? existingUploads.parent_photo_refs : parent.photo_refs || [], |
| child_photo_refs: existingUploads.child_photo_refs?.length ? existingUploads.child_photo_refs : child.photo_refs || [], |
| custom_image_reference_refs: existingUploads.custom_image_reference_refs || targetState.custom_image_reference_refs || [], |
| parent_reference_audio_ref: hasAudioUpload |
| ? existingUploads.parent_reference_audio_ref |
| : parent.reference_audio_ref || parent.parent_reference_audio_ref || null, |
| }; |
| targetState.parent_profile = { |
| display_name: parent.display_name || "Parent", |
| photo_refs: targetState.uploads.parent_photo_refs, |
| reference_audio_ref: targetState.uploads.parent_reference_audio_ref, |
| reference_audio_optional: true, |
| }; |
| targetState.children = [ |
| { |
| id: child.id || "child-1", |
| display_name: child.display_name || "Kid", |
| photo_refs: targetState.uploads.child_photo_refs, |
| }, |
| ]; |
| targetState.custom_image_reference_refs = targetState.uploads.custom_image_reference_refs; |
| targetState.accepted_goals = (targetState.accepted_goals || targetState.goals || []).map(normalizeGoal); |
| targetState.goals = targetState.accepted_goals; |
| if (!targetState.selected_goal_id && targetState.accepted_goals[0]) targetState.selected_goal_id = targetState.accepted_goals[0].id; |
| syncGenerationReferences(targetState); |
| } |
|
|
| function normalizeBootstrap(payload) { |
| const fallback = makeFallbackState(); |
| const nextState = { |
| ...fallback, |
| ...payload, |
| active_tab: "home", |
| active_theme_id: themes[payload.active_theme_id] ? payload.active_theme_id : fallback.active_theme_id, |
| goal_draft: { |
| ...fallback.goal_draft, |
| ...(payload.goal_draft || {}), |
| }, |
| diy: payload.diy || fallback.diy, |
| generation_status: payload.generation_status || fallback.generation_status, |
| }; |
| syncStateAliases(nextState); |
| return nextState; |
| } |
|
|
| function sleep(ms) { |
| return new Promise((resolve) => setTimeout(resolve, ms)); |
| } |
|
|
| function gradioEndpointFor(path) { |
| if (path === "/api/bootstrap") return "epic_bootstrap"; |
| if (path === "/api/generate-goal") return "epic_generate_goal"; |
| if (path === "/api/diy-preview") return "epic_diy_preview"; |
| return ""; |
| } |
|
|
| function parseSseData(text) { |
| const lines = String(text || "").split(/\r?\n/); |
| const eventLine = lines.find((line) => line.startsWith("event: error")); |
| const dataLine = lines.find((line) => line.startsWith("data: ")); |
| if (!dataLine) throw new Error("Gradio response did not include data."); |
| const parsed = JSON.parse(dataLine.slice(6)); |
| if (eventLine) throw new Error(Array.isArray(parsed) ? parsed.join(" ") : String(parsed)); |
| const value = Array.isArray(parsed) ? parsed[0] : parsed; |
| return typeof value === "string" ? JSON.parse(value) : value; |
| } |
|
|
| async function gradioApiJson(apiName, payload = {}) { |
| const callResponse = await fetch(`${GRADIO_API_PREFIX}/call/${apiName}`, { |
| method: "POST", |
| headers: { "content-type": "application/json" }, |
| body: JSON.stringify({ data: [JSON.stringify(payload || {})] }), |
| }); |
| if (!callResponse.ok) throw new Error(`Gradio call failed: ${callResponse.status}`); |
| const callBody = await callResponse.json(); |
| if (!callBody.event_id) throw new Error("Gradio call did not return an event id."); |
| const eventResponse = await fetch(`${GRADIO_API_PREFIX}/call/${apiName}/${callBody.event_id}`); |
| if (!eventResponse.ok) throw new Error(`Gradio event failed: ${eventResponse.status}`); |
| return parseSseData(await eventResponse.text()); |
| } |
|
|
| async function apiJson(path, options = {}) { |
| const gradioEndpoint = EMBEDDED_SPACE_MODE ? gradioEndpointFor(path) : ""; |
| if (gradioEndpoint) { |
| const payload = options.body ? JSON.parse(options.body) : {}; |
| return gradioApiJson(gradioEndpoint, payload); |
| } |
| const response = await fetch(`${API_BASE}${path}`, options); |
| if (!response.ok) throw new Error(`Request failed: ${response.status}`); |
| return response.json(); |
| } |
|
|
| function counts() { |
| const pendingReview = state.pending_review_goal ? 1 : 0; |
| const activeGoals = state.accepted_goals.filter((goal) => goal.parent_reward_state !== "approved_reward_given").length; |
| const awaiting = state.accepted_goals.filter((goal) => goal.parent_reward_state === "waiting_for_approval").length; |
| const approved = state.accepted_goals.filter((goal) => goal.parent_reward_state === "approved_reward_given").length; |
| return { pendingReview, activeGoals, awaiting, approved }; |
| } |
|
|
| function selectedGoal() { |
| return state.accepted_goals.find((goal) => goal.id === state.selected_goal_id) || state.accepted_goals[0] || null; |
| } |
|
|
| function kidModalGoal() { |
| return state.accepted_goals.find((goal) => goal.id === state.kid_modal_goal_id) || null; |
| } |
|
|
| function setTheme(themeId) { |
| if (!themes[themeId]) return; |
| state.active_theme_id = themeId; |
| document.documentElement.dataset.theme = themeToToken[themeId]; |
| render(); |
| } |
|
|
| function toggleThemeChoice(themeId) { |
| if (!themes[themeId]) return; |
| setTheme(state.active_theme_id === themeId && themeId !== "questbook" ? "questbook" : themeId); |
| } |
|
|
| function shortRefLabel(ref) { |
| if (!ref) return "Reference"; |
| return String(ref.asset_ref || uploadLabel(ref.kind)).replace(/\s+reference\s+portrait/i, "").replace(/\s+sample/i, ""); |
| } |
|
|
| function selectTab(tabId) { |
| if (tabId === "diy") { |
| openDiySurface(); |
| return; |
| } |
| state.active_tab = tabId; |
| render(); |
| } |
|
|
| function openDiySurface() { |
| state.active_tab = "diy"; |
| render(); |
| } |
|
|
| async function updateDiyPreview() { |
| const draft = state.diy?.workflow_draft || { |
| ordinary_goal: state.goal_draft.ordinary_goal || "Clean up my room before dinner", |
| selected_theme_id: state.active_theme_id, |
| selected_generation_reference_ids: state.goal_draft.selected_generation_reference_ids, |
| }; |
| try { |
| state.diy = await apiJson("/api/diy-preview", { |
| method: "POST", |
| headers: { "content-type": "application/json" }, |
| body: JSON.stringify({ |
| ordinary_goal: draft.ordinary_goal, |
| selected_theme_id: draft.selected_theme_id, |
| selected_generation_reference_ids: draft.selected_generation_reference_ids, |
| }), |
| }); |
| } catch (error) { |
| state.diy = buildLocalDiyState( |
| draft.selected_theme_id, |
| draft.ordinary_goal, |
| draft.selected_generation_reference_ids |
| ); |
| state.diy.trace_json.local_preview_api = "unavailable_using_embedded_fallback"; |
| } |
| render(); |
| } |
|
|
| function setDiyTheme(themeId) { |
| if (!themes[themeId]) return; |
| state.diy = state.diy || buildLocalDiyState(state.active_theme_id, state.goal_draft.ordinary_goal, state.goal_draft.selected_generation_reference_ids); |
| state.diy.workflow_draft.selected_theme_id = themeId; |
| updateDiyPreview(); |
| } |
|
|
| const uploadActions = { |
| parent_photo: "upload-parent-photo", |
| child_photo: "upload-child-photo", |
| parent_reference_audio: "upload-parent-audio", |
| custom_image_reference: "upload-custom-image-reference", |
| }; |
|
|
| const removeActions = { |
| parent_photo: "remove-parent-photo", |
| child_photo: "remove-child-photo", |
| parent_reference_audio: "remove-parent-audio", |
| custom_image_reference: "remove-custom-image-reference", |
| }; |
|
|
| function uploadLabel(kind) { |
| if (kind === "parent_photo") return "Parent photo"; |
| if (kind === "child_photo") return "Child photo"; |
| if (kind === "parent_reference_audio") return "Parent audio"; |
| return "Custom reference"; |
| } |
|
|
| function fileToUploadRef(kind, file) { |
| const ref = { |
| id: `${kind}-${Date.now()}-${Math.random().toString(16).slice(2, 7)}`, |
| kind, |
| asset_ref: file?.name || `${kind}-session-demo`, |
| preview_ref: file ? URL.createObjectURL(file) : null, |
| privacy_scope: "session_only", |
| created_at: new Date().toISOString(), |
| }; |
| return ref; |
| } |
|
|
| function addUpload(kind, file) { |
| const ref = fileToUploadRef(kind, file); |
| if (kind === "parent_photo") state.uploads.parent_photo_refs.push(ref); |
| if (kind === "child_photo") state.uploads.child_photo_refs.push(ref); |
| if (kind === "custom_image_reference") state.uploads.custom_image_reference_refs.push(ref); |
| if (kind === "parent_reference_audio") state.uploads.parent_reference_audio_ref = ref; |
| syncStateAliases(); |
| render(); |
| } |
|
|
| function removeUpload(kind, id) { |
| const refs = [ |
| ...state.uploads.parent_photo_refs, |
| ...state.uploads.child_photo_refs, |
| ...state.uploads.custom_image_reference_refs, |
| state.uploads.parent_reference_audio_ref, |
| ].filter(Boolean); |
| const removed = refs.find((ref) => ref.kind === kind && (!id || ref.id === id)); |
| if (removed?.preview_ref?.startsWith("blob:")) URL.revokeObjectURL(removed.preview_ref); |
| if (kind === "parent_photo") state.uploads.parent_photo_refs = state.uploads.parent_photo_refs.filter((ref) => ref.id !== id); |
| if (kind === "child_photo") state.uploads.child_photo_refs = state.uploads.child_photo_refs.filter((ref) => ref.id !== id); |
| if (kind === "custom_image_reference") state.uploads.custom_image_reference_refs = state.uploads.custom_image_reference_refs.filter((ref) => ref.id !== id); |
| if (kind === "parent_reference_audio") state.uploads.parent_reference_audio_ref = null; |
| syncStateAliases(); |
| render(); |
| } |
|
|
| function syncGenerationReferences(targetState = state) { |
| const refs = [ |
| ...targetState.uploads.parent_photo_refs, |
| ...targetState.uploads.child_photo_refs, |
| ...targetState.uploads.custom_image_reference_refs, |
| ]; |
| targetState.generation_references = refs.map((ref) => ({ |
| id: `gen-${ref.id}`, |
| source: ref.kind, |
| upload_ref_id: ref.id, |
| selected: targetState.goal_draft.selected_generation_reference_ids.includes(ref.id), |
| })); |
| } |
|
|
| function toggleGenerationReference(refId) { |
| const selected = state.goal_draft.selected_generation_reference_ids; |
| state.goal_draft.selected_generation_reference_ids = selected.includes(refId) |
| ? selected.filter((item) => item !== refId) |
| : [...selected, refId]; |
| syncGenerationReferences(); |
| render(); |
| } |
|
|
| async function generateGoal() { |
| const goalText = state.goal_draft.ordinary_goal.trim(); |
| if (!goalText) return; |
| const started = performance.now(); |
| state.generation_status = { |
| state: "generating", |
| message: EMBEDDED_SPACE_MODE |
| ? "Generating through the hosted Gradio backend..." |
| : "Generating through the local app backend...", |
| backend_transport: EMBEDDED_SPACE_MODE ? "gradio_blocks_named_api" : "fastapi_route", |
| fallback_used: null, |
| }; |
| render(); |
| try { |
| const [generated] = await Promise.all([ |
| apiJson("/api/generate-goal", { |
| method: "POST", |
| headers: { "content-type": "application/json" }, |
| body: JSON.stringify({ |
| ordinary_goal: goalText, |
| ui_theme_id: state.active_theme_id, |
| selected_generation_reference_ids: state.goal_draft.selected_generation_reference_ids, |
| audio_used_parent_reference: Boolean(state.uploads.parent_reference_audio_ref), |
| }), |
| }), |
| sleep(500), |
| ]); |
| state.pending_review_goal = normalizeGoal(generated); |
| state.generation_status = { |
| state: "success", |
| message: `Generated through ${state.pending_review_goal.provenance.backend_transport || "app backend"}; fallback_used=${String(Boolean(state.pending_review_goal.provenance.fallback_used))}.`, |
| backend_transport: state.pending_review_goal.provenance.backend_transport || "app_backend", |
| fallback_used: Boolean(state.pending_review_goal.provenance.fallback_used), |
| latency_ms: Math.round(performance.now() - started), |
| }; |
| } catch (error) { |
| state.pending_review_goal = createGoal( |
| goalText, |
| state.active_theme_id, |
| `goal-review-${Date.now()}`, |
| state.goal_draft.selected_generation_reference_ids, |
| Boolean(state.uploads.parent_reference_audio_ref) |
| ); |
| state.pending_review_goal.provenance.backend_transport = "browser_fallback"; |
| state.pending_review_goal.provenance.fallback_reason = String(error?.message || error || "backend unavailable"); |
| state.generation_status = { |
| state: "fallback", |
| message: "Hosted backend was unavailable, so this preview used browser fallback.", |
| backend_transport: "browser_fallback", |
| fallback_used: true, |
| latency_ms: Math.round(performance.now() - started), |
| }; |
| } |
| state.active_tab = "parent_goals"; |
| render(); |
| } |
|
|
| function acceptGoal() { |
| if (!state.pending_review_goal) return; |
| const accepted = { |
| ...state.pending_review_goal, |
| review_state: "accepted", |
| kid_completion_state: "not_started", |
| parent_reward_state: "not_reviewed", |
| }; |
| state.accepted_goals.unshift(accepted); |
| state.goals = state.accepted_goals; |
| state.selected_goal_id = accepted.id; |
| state.pending_review_goal = null; |
| state.active_tab = "kid_goals"; |
| render(); |
| } |
|
|
| function cancelGoal() { |
| state.pending_review_goal = null; |
| state.active_tab = "parent_goals"; |
| render(); |
| } |
|
|
| function updateOverlay(text) { |
| if (!state.pending_review_goal) return; |
| state.pending_review_goal.overlay_text.text = text; |
| } |
|
|
| function completeGoal(goalId) { |
| const goal = state.accepted_goals.find((item) => item.id === goalId); |
| if (!goal) return; |
| goal.kid_completion_state = "completed"; |
| goal.parent_reward_state = "waiting_for_approval"; |
| render(); |
| } |
|
|
| function approveGoal(goalId) { |
| const goal = state.accepted_goals.find((item) => item.id === goalId); |
| if (!goal) return; |
| goal.kid_completion_state = "completed"; |
| goal.parent_reward_state = "approved_reward_given"; |
| render(); |
| } |
|
|
| function openKidGoalCard(goalId) { |
| const goal = state.accepted_goals.find((item) => item.id === goalId); |
| if (!goal) return; |
| state.selected_goal_id = goal.id; |
| state.kid_modal_goal_id = goal.id; |
| state.active_tab = "kid_goals"; |
| render(); |
| } |
|
|
| function closeKidGoalCard() { |
| state.kid_modal_goal_id = null; |
| render(); |
| } |
|
|
| function setAudioButtonState(button, isPlaying) { |
| if (!button) return; |
| const icon = button.querySelector("[data-audio-icon]"); |
| button.setAttribute("aria-pressed", String(isPlaying)); |
| button.setAttribute("aria-label", isPlaying ? "Pause audio" : "Play audio"); |
| if (icon) icon.innerHTML = isPlaying ? icons.pause : icons.play; |
| } |
|
|
| function toggleAudio(button) { |
| const shell = button.closest(".audio-shell"); |
| const audio = shell?.querySelector("audio"); |
| if (!audio) return; |
| document.querySelectorAll(".audio-shell audio").forEach((item) => { |
| if (item !== audio) { |
| item.pause(); |
| setAudioButtonState(item.closest(".audio-shell")?.querySelector(".audio-toggle"), false); |
| } |
| }); |
| if (audio.paused) { |
| audio.play() |
| .then(() => setAudioButtonState(button, true)) |
| .catch(() => setAudioButtonState(button, false)); |
| } else { |
| audio.pause(); |
| setAudioButtonState(button, false); |
| } |
| } |
|
|
| function flourishCorners() { |
| const flourish = '<svg viewBox="0 0 60 60" fill="none"><path d="M8 52c16-3 19-10 20-22 1 12 5 19 24 22M8 8c16 3 19 10 20 22 1-12 5-19 24-22" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>'; |
| return ` |
| <span class="corner-flourish tl">${flourish}</span> |
| <span class="corner-flourish tr">${flourish}</span> |
| <span class="corner-flourish bl">${flourish}</span> |
| <span class="corner-flourish br">${flourish}</span> |
| `; |
| } |
|
|
| function shellHeader() { |
| return ` |
| <div class="app-head"> |
| <div class="wordmark" aria-label="Epic Errands"> |
| <span class="display">Epic Errands</span> |
| <span class="sub">${escapeHtml(theme().parentTitle)} / ${escapeHtml(theme().kidTitle)}</span> |
| </div> |
| </div> |
| `; |
| } |
|
|
| function navHtml() { |
| const c = counts(); |
| const tabs = [ |
| ["home", "Home", icons.home, ""], |
| ["parent_goals", "Parent", icons.wand, c.pendingReview + c.awaiting ? c.pendingReview + c.awaiting : ""], |
| ["kid_goals", "Kid", icons.kid, c.activeGoals || ""], |
| ["settings", "Settings", icons.settings, ""], |
| ["diy", "DIY", icons.lab, ""], |
| ]; |
| return ` |
| <nav class="mobile-tabs" data-design-id="mobile-tab-nav" aria-label="Primary"> |
| ${tabs |
| .map(([id, label, icon, badge]) => ` |
| <button class="tab-btn" type="button" data-action="select-tab" data-tab="${id}" aria-pressed="${state.active_tab === id}"> |
| ${icon}<span>${escapeHtml(label)}</span>${badge ? `<b>${escapeHtml(badge)}</b>` : ""} |
| </button> |
| `) |
| .join("")} |
| </nav> |
| `; |
| } |
|
|
| function themePickerHtml() { |
| return ` |
| <section class="panel" data-design-id="app-theme-picker"> |
| <div class="panel-head"> |
| <h2>App Styling</h2> |
| <span>${escapeHtml(theme().label)}</span> |
| </div> |
| <div class="theme-image-grid"> |
| ${Object.entries(themes) |
| .map(([id, item]) => ` |
| <button class="theme-image-btn" type="button" data-action="select-theme" data-theme="${id}" aria-pressed="${state.active_theme_id === id}"> |
| <img src="${escapeHtml(asset(addElementsBaseThemeImages[id] || assets["project-outline"][id]?.image || assets["project-outline"].questbook.image))}" alt=""> |
| <span>${escapeHtml(item.label)}</span> |
| </button> |
| `) |
| .join("")} |
| </div> |
| </section> |
| `; |
| } |
|
|
| function audioControlHtml(src, label, id) { |
| return ` |
| <div class="audio-shell" data-design-id="audio-play-hook"> |
| <button class="audio-toggle" type="button" data-action="toggle-audio" aria-pressed="false" aria-label="Play ${escapeHtml(label)}"> |
| <span class="audio-toggle__icon" data-audio-icon>${icons.play}</span> |
| </button> |
| <span class="audio-label">${escapeHtml(label)}</span> |
| <audio class="visually-hidden-audio" preload="metadata" src="${escapeHtml(asset(src))}" data-audio-id="${escapeHtml(id || src)}"></audio> |
| </div> |
| `; |
| } |
|
|
| function uploadPanel(title, designId, kind, refs, accept) { |
| const items = refs.length |
| ? refs |
| .map((ref, index) => ` |
| <div class="upload-chip${kind === "parent_reference_audio" ? " upload-chip--stack" : ""}"> |
| <div class="upload-chip__row"> |
| <span class="upload-chip__main"> |
| ${ref.preview_ref && kind !== "parent_reference_audio" ? `<img class="upload-thumb" src="${escapeHtml(asset(ref.preview_ref))}" alt="">` : ""} |
| <span>${escapeHtml(ref.asset_ref || `${title} ${index + 1}`)}</span> |
| </span> |
| <button class="icon-mini" type="button" data-action="${removeActions[kind]}" data-kind="${kind}" data-id="${ref.id}" aria-label="Remove ${escapeHtml(title)}">${icons.x}</button> |
| </div> |
| ${ref.preview_ref && kind === "parent_reference_audio" ? audioControlHtml(ref.preview_ref, ref.asset_ref || title, ref.id) : ""} |
| </div> |
| `) |
| .join("") |
| : `<p class="empty-line">Session only</p>`; |
| return ` |
| <section class="panel" data-design-id="${designId}"> |
| <div class="panel-head"> |
| <h2>${escapeHtml(title)}</h2> |
| <label class="btn-ghost compact upload-trigger" data-action="${uploadActions[kind]}"> |
| ${icons.plus}<span>Add</span> |
| <input class="visually-hidden-file" type="file" data-upload-kind="${kind}" accept="${escapeHtml(accept)}"> |
| </label> |
| </div> |
| <div class="upload-list">${items}</div> |
| </section> |
| `; |
| } |
|
|
| function photoUploadPanel(title, designId, kind, refs, accept) { |
| const items = refs.length |
| ? refs |
| .map((ref, index) => ` |
| <div class="photo-token"> |
| <div class="photo-token__frame"> |
| ${ref.preview_ref ? `<img src="${escapeHtml(asset(ref.preview_ref))}" alt="${escapeHtml(ref.asset_ref || `${title} ${index + 1}`)}">` : `<span>${escapeHtml(uploadLabel(kind).slice(0, 1))}</span>`} |
| <button class="icon-mini photo-token__remove" type="button" data-action="${removeActions[kind]}" data-kind="${kind}" data-id="${ref.id}" aria-label="Remove ${escapeHtml(ref.asset_ref || title)}">${icons.x}</button> |
| </div> |
| <span>${escapeHtml(shortRefLabel(ref))}</span> |
| </div> |
| `) |
| .join("") |
| : `<p class="empty-line">Session only</p>`; |
| return ` |
| <section class="panel" data-design-id="${designId}"> |
| <div class="panel-head"> |
| <h2>${escapeHtml(title)}</h2> |
| <label class="btn-ghost compact upload-trigger" data-action="${uploadActions[kind]}"> |
| ${icons.plus}<span>Add</span> |
| <input class="visually-hidden-file" type="file" data-upload-kind="${kind}" accept="${escapeHtml(accept)}"> |
| </label> |
| </div> |
| <div class="photo-token-grid">${items}</div> |
| </section> |
| `; |
| } |
|
|
| function generationReferencesHtml() { |
| const refs = [ |
| ...state.uploads.parent_photo_refs, |
| ...state.uploads.child_photo_refs, |
| ...state.uploads.custom_image_reference_refs, |
| ]; |
| return ` |
| <section class="panel" data-design-id="generation-reference-picker"> |
| <div class="panel-head"> |
| <h2>Generation References</h2> |
| <span>${refs.length ? `${refs.length} available` : "Optional"}</span> |
| </div> |
| <div class="reference-avatar-grid"> |
| ${refs.length |
| ? refs |
| .map((ref) => ` |
| <button class="reference-avatar" type="button" data-action="select-generation-reference" data-ref-id="${ref.id}" aria-pressed="${state.goal_draft.selected_generation_reference_ids.includes(ref.id)}"> |
| ${ref.preview_ref ? `<img src="${escapeHtml(asset(ref.preview_ref))}" alt="${escapeHtml(ref.asset_ref || uploadLabel(ref.kind))}">` : `<span class="reference-avatar__initial">${escapeHtml(uploadLabel(ref.kind).slice(0, 1))}</span>`} |
| <span>${escapeHtml(shortRefLabel(ref))}</span> |
| <small>${state.goal_draft.selected_generation_reference_ids.includes(ref.id) ? "Selected" : "Tap"}</small> |
| </button> |
| `) |
| .join("") |
| : `<p class="empty-line">Parent photo, child photo, or custom image can be added above.</p>`} |
| </div> |
| </section> |
| `; |
| } |
|
|
| function generationModeHtml() { |
| return ` |
| <section class="panel" data-design-id="generation-mode-control"> |
| <div class="panel-head"> |
| <h2>Generation Mode</h2> |
| <span>Quality selected</span> |
| </div> |
| <div class="mode-row"> |
| <button class="mode-btn" type="button" data-mode="quality" aria-pressed="true"> |
| <b>Quality</b> |
| <span>1024 x 1024</span> |
| </button> |
| <button class="mode-btn" type="button" data-mode="speed" disabled aria-disabled="true"> |
| <b>Speed</b> |
| <span>720 x 720 planned</span> |
| </button> |
| </div> |
| </section> |
| `; |
| } |
|
|
| function generationStatusHtml() { |
| const status = state.generation_status; |
| if (!status || status.state === "ready") return ""; |
| const label = status.state === "fallback" ? "Fallback" : "Backend"; |
| return ` |
| <div class="statusbar generation-status" data-generation-status="${escapeHtml(status.state)}"> |
| <span class="statusbar__dot"></span> |
| <div class="statusbar__info"> |
| <b>${escapeHtml(label)}</b> |
| ${escapeHtml(status.message)} |
| ${status.latency_ms ? `<small>${escapeHtml(status.latency_ms)}ms</small>` : ""} |
| </div> |
| </div> |
| `; |
| } |
|
|
| function homeHtml() { |
| const c = counts(); |
| const current = selectedGoal(); |
| return ` |
| <section class="lead compact-lead"> |
| <h1>${escapeHtml(theme().goalNoun)} Board</h1> |
| <p>${escapeHtml(theme().kidTitle)} has ${c.activeGoals} active ${c.activeGoals === 1 ? "card" : "cards"}.</p> |
| </section> |
| <section class="summary-grid" data-design-id="home-status-summary"> |
| <div class="summary-tile"><b>${c.pendingReview}</b><span>Review</span></div> |
| <div class="summary-tile"><b>${c.awaiting}</b><span>Waiting</span></div> |
| <div class="summary-tile"><b>${c.approved}</b><span>Rewards</span></div> |
| </section> |
| <section class="panel current-goal" data-design-id="current-kid-goal-shortcut"> |
| <div class="panel-head"> |
| <h2>Current Kid Card</h2> |
| <button class="btn-ghost compact" type="button" data-action="open-kid-goal">${icons.kid}<span>Open</span></button> |
| </div> |
| ${current ? goalMiniHtml(current) : `<p class="empty-line">No active kid card yet.</p>`} |
| </section> |
| `; |
| } |
|
|
| function settingsHtml() { |
| const audioRefs = state.uploads.parent_reference_audio_ref ? [state.uploads.parent_reference_audio_ref] : []; |
| return ` |
| ${themePickerHtml()} |
| ${photoUploadPanel("Parent Photos", "parent-details-panel", "parent_photo", state.uploads.parent_photo_refs, "image/*")} |
| ${uploadPanel("Parent Reference Audio", "parent-details-panel", "parent_reference_audio", audioRefs, "audio/*")} |
| ${photoUploadPanel("Child Photos", "children-details-panel", "child_photo", state.uploads.child_photo_refs, "image/*")} |
| ${photoUploadPanel("Custom Image Reference", "custom-image-reference-panel", "custom_image_reference", state.uploads.custom_image_reference_refs, "image/*")} |
| ${generationModeHtml()} |
| <div class="statusbar"> |
| <span class="statusbar__dot"></span> |
| <div class="statusbar__info"><b>Audio enabled.</b> Parent reference audio is optional.</div> |
| </div> |
| `; |
| } |
|
|
| function parentGoalsHtml() { |
| const isGenerating = state.generation_status?.state === "generating"; |
| return ` |
| <section class="panel" data-design-id="create-goal-form"> |
| <div class="panel-head"> |
| <h2>Create ${escapeHtml(theme().goalNoun)}</h2> |
| <span>Quality</span> |
| </div> |
| <label class="field"> |
| <span class="lab">Ordinary goal</span> |
| <textarea class="field__input goal-textarea" data-field="goal-draft">${escapeHtml(state.goal_draft.ordinary_goal)}</textarea> |
| </label> |
| ${generationReferencesHtml()} |
| <button class="btn-primary" type="button" data-action="generate-goal" ${isGenerating ? "disabled" : ""}> |
| ${icons.wand}<span>${isGenerating ? "Generating..." : "Generate Goal"}</span> |
| </button> |
| ${generationStatusHtml()} |
| </section> |
| ${state.pending_review_goal ? reviewGoalHtml(state.pending_review_goal) : ""} |
| ${approvalQueueHtml()} |
| `; |
| } |
|
|
| function reviewGoalHtml(goal) { |
| return ` |
| <section class="review-panel" data-design-id="review-goal-panel"> |
| <div class="panel-head"> |
| <h2>Review Goal</h2> |
| <span>${escapeHtml(goal.media.image_output_size_px)} x ${escapeHtml(goal.media.image_output_size_px)}</span> |
| </div> |
| <div class="media-square"> |
| <img src="${asset(goal.media.image_asset_ref)}" alt="${escapeHtml(goal.generated_title)} generated image"> |
| <div class="goal-overlay editable" data-design-id="editable-goal-overlay" contenteditable="true" role="textbox" aria-label="Editable goal overlay">${escapeHtml(goal.overlay_text.text)}</div> |
| </div> |
| <div class="copy-block"> |
| <h3>${escapeHtml(goal.generated_title)}</h3> |
| <p>${escapeHtml(goal.generated_narration)}</p> |
| <b>${escapeHtml(goal.generated_reward_label)}</b> |
| </div> |
| ${audioControlHtml(goal.media.audio_asset_ref, "Generated narration", `review-${goal.id}`)} |
| <details class="provenance"> |
| <summary>Provenance</summary> |
| <p>${escapeHtml(goal.provenance.text_model_id)} / ${escapeHtml(goal.provenance.image_model_id)} / ${escapeHtml(goal.provenance.audio_model_id)}</p> |
| <p>${escapeHtml(goal.provenance.image_fallback_source || "generated_v2_fixture")} / ${escapeHtml(goal.provenance.add_elements_contract || "not_applied")}</p> |
| <p>fallback_used=${escapeHtml(String(goal.provenance.fallback_used))}</p> |
| </details> |
| <div class="review-actions"> |
| <button class="btn-ghost" type="button" data-action="cancel-goal">${icons.x}<span>Cancel</span></button> |
| <button class="btn-primary" type="button" data-action="accept-goal">${icons.check}<span>Accept</span></button> |
| </div> |
| </section> |
| `; |
| } |
|
|
| function approvalQueueHtml() { |
| const rows = state.accepted_goals; |
| return ` |
| <section class="panel" data-design-id="parent-approval-queue"> |
| <div class="panel-head"> |
| <h2>Reward Queue</h2> |
| <span>${counts().awaiting} waiting</span> |
| </div> |
| <div class="parent-list"> |
| ${rows |
| .map((goal) => { |
| const waiting = goal.parent_reward_state === "waiting_for_approval"; |
| const approved = goal.parent_reward_state === "approved_reward_given"; |
| return ` |
| <article class="parent-row"> |
| <div> |
| <div class="parent-row__title">${escapeHtml(goal.generated_title)}</div> |
| <div class="parent-row__meta">${escapeHtml(goal.ordinary_goal)} - ${escapeHtml(goal.generated_reward_label)}</div> |
| </div> |
| <div class="parent-actions"> |
| ${waiting ? `<span class="parent-row__status">Waiting</span>` : ""} |
| <button class="btn-ghost" type="button" data-action="approve-goal" data-goal-id="${goal.id}" ${waiting ? "" : "disabled"}> |
| ${icons.check}<span>${approved ? "Approved" : "Approve"}</span> |
| </button> |
| </div> |
| </article> |
| `; |
| }) |
| .join("")} |
| </div> |
| </section> |
| `; |
| } |
|
|
| function goalMiniHtml(goal) { |
| const isSelected = selectedGoal()?.id === goal.id; |
| return ` |
| <button class="mini-goal" type="button" data-action="open-goal-card" data-goal-id="${goal.id}" aria-pressed="${isSelected}"> |
| <img src="${asset(goal.media.image_asset_ref)}" alt="${escapeHtml(goal.generated_title)} thumbnail"> |
| <span> |
| <b>${escapeHtml(goal.generated_title)}</b> |
| <small>${escapeHtml(goal.ordinary_goal)}</small> |
| </span> |
| </button> |
| `; |
| } |
|
|
| function kidGoalsHtml() { |
| const modalGoal = kidModalGoal(); |
| return ` |
| <section class="panel" data-design-id="kid-goal-thumbnail-grid"> |
| <div class="panel-head"> |
| <h2>${escapeHtml(theme().kidTitle)}</h2> |
| <span>${state.accepted_goals.length} cards</span> |
| </div> |
| <div class="thumb-grid"> |
| ${state.accepted_goals.length ? state.accepted_goals.map(goalMiniHtml).join("") : `<p class="empty-line">No accepted goals yet.</p>`} |
| </div> |
| </section> |
| ${modalGoal ? kidGoalModalHtml(modalGoal) : ""} |
| `; |
| } |
|
|
| function kidGoalModalHtml(goal) { |
| const waiting = goal.parent_reward_state === "waiting_for_approval"; |
| const approved = goal.parent_reward_state === "approved_reward_given"; |
| return ` |
| <div class="kid-modal" data-design-id="kid-goal-card-modal" role="presentation"> |
| <section class="kid-card kid-card-modal no-generated-steps" data-design-id="kid-goal-card" role="dialog" aria-modal="true" aria-labelledby="kid-goal-title"> |
| <button class="icon-mini kid-modal__close" type="button" data-action="close-kid-goal" aria-label="Close goal card">${icons.x}</button> |
| <div class="media-square"> |
| <img src="${asset(goal.media.image_asset_ref)}" alt="${escapeHtml(goal.generated_title)} generated image"> |
| <div class="goal-overlay readonly" data-design-id="read-only-goal-overlay">${escapeHtml(goal.overlay_text.text)}</div> |
| </div> |
| <div class="copy-block"> |
| <h3 id="kid-goal-title">${escapeHtml(goal.generated_title)}</h3> |
| <p>${escapeHtml(goal.generated_narration)}</p> |
| <b>${escapeHtml(goal.generated_reward_label)}</b> |
| </div> |
| ${audioControlHtml(goal.media.audio_asset_ref, "Goal narration", `kid-${goal.id}`)} |
| <button class="btn-primary" type="button" data-action="complete-goal" data-goal-id="${goal.id}" ${waiting || approved ? "disabled" : ""}> |
| ${icons.check}<span>${approved ? "Reward Given" : waiting ? "Waiting" : "I Did It"}</span> |
| </button> |
| ${waiting ? `<p class="state-note"><strong>${escapeHtml(themes[goal.theme_id_at_creation].parentTitle)}:</strong> ${escapeHtml(themes[goal.theme_id_at_creation].waitingCopy)}</p>` : ""} |
| </section> |
| </div> |
| `; |
| } |
|
|
| function diyRedirectHtml() { |
| return ` |
| <section class="panel diy-frame-panel" data-design-id="diy-workflow-embed"> |
| <div class="panel-head"> |
| <h2>DIY Lab</h2> |
| <span>Workflow canvas</span> |
| </div> |
| <div class="diy-frame-wrap"> |
| <iframe class="diy-workflow-frame" src="/diy-workflow/" title="Epic Errands DIY workflow canvas" loading="lazy"></iframe> |
| </div> |
| </section> |
| `; |
| } |
|
|
| function stepHtml(label, step) { |
| return ` |
| <div class="diy-step"> |
| <b>${escapeHtml(label)}</b> |
| <span>${escapeHtml(step.model || step.result_asset_ref || step.overlay_text || "Ready")}</span> |
| <small>${escapeHtml(step.runtime || step.output_size_px || step.fallback_used || "")}</small> |
| </div> |
| `; |
| } |
|
|
| function diyInlineHtml() { |
| state.diy = state.diy || buildLocalDiyState(state.active_theme_id, state.goal_draft.ordinary_goal, state.goal_draft.selected_generation_reference_ids); |
| const draft = state.diy.workflow_draft; |
| const preview = state.diy.preview; |
| const trace = state.diy.trace_json || {}; |
| return ` |
| <div class="diy-topline"> |
| <button class="btn-ghost compact" type="button" data-action="back-main-app">${icons.home}<span>Main App</span></button> |
| <span class="empty-line">Isolated surface</span> |
| </div> |
| <section class="panel diy-native-panel" data-design-id="diy-workflow-native-link"> |
| <div class="panel-head"> |
| <h1>DIY Lab</h1> |
| <span>Embedded Space mode</span> |
| </div> |
| <p class="empty-line diy-native-note"> |
| The workflow mirror opens inside this Space so the private hosted app does not navigate into a recursive Gradio frame. |
| </p> |
| </section> |
| <section class="panel" data-design-id="diy-workflow-mirror"> |
| <div class="panel-head"> |
| <h2>V2 Preview Inputs</h2> |
| <span>${escapeHtml(themes[draft.selected_theme_id]?.label || themes.questbook.label)}</span> |
| </div> |
| <label class="field"> |
| <span class="lab">Workflow draft</span> |
| <textarea class="field__input goal-textarea" data-field="diy-goal">${escapeHtml(draft.ordinary_goal)}</textarea> |
| </label> |
| <div class="segmented small"> |
| ${Object.entries(themes) |
| .map(([id, item]) => ` |
| <button class="seg-btn" type="button" data-action="diy-theme" data-theme="${id}" aria-pressed="${draft.selected_theme_id === id}"> |
| <span>${escapeHtml(item.label)}</span> |
| </button> |
| `) |
| .join("")} |
| </div> |
| <div class="diy-ref-list" aria-label="Selected generation references"> |
| ${draft.selected_generation_reference_ids.map((ref) => `<span>${escapeHtml(ref)}</span>`).join("")} |
| </div> |
| <button class="btn-ghost" type="button" data-action="update-diy-preview">${icons.wand}<span>Update Preview</span></button> |
| </section> |
| <section class="panel"> |
| <div class="panel-head"> |
| <h2>Pipeline</h2> |
| <span>Quality</span> |
| </div> |
| <div class="diy-step-grid"> |
| ${stepHtml("Text / Quest JSON", trace.text_step || {})} |
| ${stepHtml("Image Request / Result", trace.image_step || {})} |
| ${stepHtml("Audio Request / Result", trace.audio_step || {})} |
| ${stepHtml("Composed Card", trace.composed_card_step || {})} |
| </div> |
| </section> |
| ${preview ? ` |
| <section class="kid-card diy-preview"> |
| <div class="media-square"> |
| <img src="${asset(preview.media.image_asset_ref)}" alt="${escapeHtml(preview.generated_title)} DIY preview"> |
| <div class="goal-overlay readonly">${escapeHtml(preview.overlay_text.text)}</div> |
| </div> |
| <div class="copy-block"> |
| <h3>${escapeHtml(preview.generated_title)}</h3> |
| <p>${escapeHtml(preview.generated_narration)}</p> |
| <b>${escapeHtml(preview.generated_reward_label)}</b> |
| </div> |
| ${audioControlHtml(preview.media.audio_asset_ref, "Preview narration", "diy-preview")} |
| </section> |
| ` : ""} |
| <section class="panel" data-design-id="diy-save-back-notice"> |
| <div class="panel-head"> |
| <h2>Trace</h2> |
| <button class="btn-ghost compact" type="button" data-action="save-to-app" disabled>Coming soon</button> |
| </div> |
| <details class="provenance" open> |
| <summary>Model details</summary> |
| <p>${escapeHtml(JSON.stringify(trace.quality_mode || trace.quality_model_contract || {}, null, 2))}</p> |
| </details> |
| <pre class="trace-json">${escapeHtml(JSON.stringify(trace, null, 2))}</pre> |
| </section> |
| `; |
| } |
|
|
| function currentScreenHtml() { |
| if (state.active_tab === "settings") return settingsHtml(); |
| if (state.active_tab === "parent_goals") return parentGoalsHtml(); |
| if (state.active_tab === "kid_goals") return kidGoalsHtml(); |
| if (state.active_tab === "diy") return diyRedirectHtml(); |
| return homeHtml(); |
| } |
|
|
| function renderShell() { |
| root.innerHTML = ` |
| <article class="card ornate v2-shell"> |
| ${flourishCorners()} |
| <div class="card-inner app-shell"> |
| <div data-shell-region="header"></div> |
| <div data-shell-region="nav"></div> |
| <main class="app-screen" data-shell-region="screen" data-active-tab="${escapeHtml(state.active_tab)}"></main> |
| </div> |
| </article> |
| `; |
| } |
|
|
| function render() { |
| if (!root) return; |
| document.documentElement.dataset.theme = themeToToken[state.active_theme_id]; |
| if (!root.querySelector('[data-shell-region="screen"]')) renderShell(); |
| root.querySelector('[data-shell-region="header"]').innerHTML = shellHeader(); |
| root.querySelector('[data-shell-region="nav"]').innerHTML = navHtml(); |
| const screen = root.querySelector('[data-shell-region="screen"]'); |
| screen.dataset.activeTab = state.active_tab; |
| screen.innerHTML = currentScreenHtml(); |
| } |
|
|
| document.addEventListener("click", (event) => { |
| const button = event.target.closest("[data-action]"); |
| if (!button || button.disabled) return; |
| const action = button.dataset.action; |
| if (action === "select-tab") selectTab(button.dataset.tab); |
| if (action === "select-theme") toggleThemeChoice(button.dataset.theme); |
| if (Object.values(removeActions).includes(action)) removeUpload(button.dataset.kind, button.dataset.id); |
| if (action === "select-generation-reference") toggleGenerationReference(button.dataset.refId); |
| if (action === "generate-goal") generateGoal(); |
| if (action === "accept-goal") acceptGoal(); |
| if (action === "cancel-goal") cancelGoal(); |
| if (action === "toggle-audio") toggleAudio(button); |
| if (action === "open-diy-surface") openDiySurface(); |
| if (action === "back-main-app") selectTab("home"); |
| if (action === "diy-theme") setDiyTheme(button.dataset.theme); |
| if (action === "update-diy-preview") updateDiyPreview(); |
| if (action === "open-kid-goal") selectTab("kid_goals"); |
| if (action === "open-goal-card") openKidGoalCard(button.dataset.goalId); |
| if (action === "close-kid-goal") closeKidGoalCard(); |
| if (action === "complete-goal") completeGoal(button.dataset.goalId); |
| if (action === "approve-goal") approveGoal(button.dataset.goalId); |
| }); |
|
|
| document.addEventListener("change", (event) => { |
| const input = event.target.closest("[data-upload-kind]"); |
| if (!input || !input.files || !input.files[0]) return; |
| addUpload(input.dataset.uploadKind, input.files[0]); |
| input.value = ""; |
| }); |
|
|
| document.addEventListener("input", (event) => { |
| const field = event.target.closest("[data-field]"); |
| if (!field) return; |
| if (field.dataset.field === "goal-draft") state.goal_draft.ordinary_goal = field.value; |
| if (field.dataset.field === "diy-goal") { |
| state.diy = state.diy || buildLocalDiyState(state.active_theme_id, state.goal_draft.ordinary_goal, state.goal_draft.selected_generation_reference_ids); |
| state.diy.workflow_draft.ordinary_goal = field.value; |
| } |
| }); |
|
|
| document.addEventListener("ended", (event) => { |
| const audio = event.target.closest(".audio-shell audio"); |
| if (!audio) return; |
| setAudioButtonState(audio.closest(".audio-shell")?.querySelector(".audio-toggle"), false); |
| }, true); |
|
|
| document.addEventListener("blur", (event) => { |
| const overlay = event.target.closest('[data-design-id="editable-goal-overlay"]'); |
| if (overlay) updateOverlay(overlay.textContent.trim()); |
| }, true); |
|
|
| async function init() { |
| try { |
| state = normalizeBootstrap(await apiJson("/api/bootstrap")); |
| } catch (error) { |
| state = makeFallbackState(); |
| } |
| setTheme(state.active_theme_id); |
| } |
|
|
| init(); |
| })(); |
|
|