From 3d5e11ed6af98f1b7a0e097f2222ad8169865b53 Mon Sep 17 00:00:00 2001 From: Lee Harrold Date: Tue, 11 Aug 2026 17:16:37 -0400 Subject: [PATCH] fix(web): point coach avatar editing at the function that exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoachAvatarEdit POSTed {prompt, style, count} to `/motivational-images` and expected {images: []}. That endpoint is `sendMotivationalImages(event, context)` — a Cloud Scheduler background job. It is not an HTTP handler, takes no such body and returns no such shape, so the Generate button could never have worked. Closes #22. The component is NOT orphaned: App.jsx routes /my-coaches/:coachId/avatar to it and CoachEdit.jsx:384 navigates there, so this is a repoint rather than a delete. Repointing meant changing the interaction, not just the URL. The real generator is PhotoMaker, which restyles a real face and cannot invent one from a description — `generateCoachAvatars` calls `storeSelfie` unconditionally, so a photo is required. The UI now asks for one, and the description becomes optional steering on top of the style's own prompt. The request matches the JSON path the generator actually implements ({coachId, selfie_base64, selfie_mime, style, prompt}) and reads {avatars, failedStyles} back. Details that follow from matching the real contract: - The style list mirrors AVATAR_STYLES in avatar-generation.js, including the model's own "Disney Charactor" misspelling. The previous list (realistic, professional, cartoon, fitness, athletic) matched nothing server-side and was never rendered anyway — the generator silently falls back to every style when it does not recognise one. - Partial success is surfaced. The generator returns `failedStyles` alongside whatever succeeded; a short grid is otherwise unexplained. - Errors come from the generator's own message rather than a generic toast. - The VITE_COACH_AVATAR_GENERATOR_URL override is honoured, as AvatarUpload already does. Without it this component would break in any deploy that points the generator at its own URL. - The default prompt ("A ... fitness coach") is gone. It assumed the fitness-era product and overrode the style the creator picked. Also removes a second phantom endpoint found while checking for other bad callers: CoachBuilderContext POSTed to `/coach-avatar-generator/save-avatar`, which has never existed either — the generator does not route on path, so the request could only ever be read as a malformed generation request. It was redundant as well as dead, since the insert directly above it already persists avatar_url, avatar_style and original_selfie_url. It could not even fail honestly: `fetch` does not reject on a 4xx, so its catch almost never ran, and when it did it told the creator the avatar had failed to save after the insert had saved it. Verified: webapp builds clean; no caller in webapp/, mobile/src or mobile/app invokes /motivational-images or /save-avatar as an HTTP endpoint any more. The generation round trip itself is unverified against a live generator — it needs Replicate and GCS credentials, which the local stack does not carry. Co-Authored-By: Claude Opus 5 --- .../components/MyCoaches/CoachAvatarEdit.jsx | 153 ++++++++++++++---- webapp/src/contexts/CoachBuilderContext.jsx | 33 ++-- 2 files changed, 134 insertions(+), 52 deletions(-) diff --git a/webapp/src/components/MyCoaches/CoachAvatarEdit.jsx b/webapp/src/components/MyCoaches/CoachAvatarEdit.jsx index 2f37b48..95b3d28 100644 --- a/webapp/src/components/MyCoaches/CoachAvatarEdit.jsx +++ b/webapp/src/components/MyCoaches/CoachAvatarEdit.jsx @@ -16,13 +16,19 @@ const CoachAvatarEdit = () => { const [generating, setGenerating] = useState(false); const [generatedAvatars, setGeneratedAvatars] = useState([]); const [selectedAvatar, setSelectedAvatar] = useState(null); - + const [selfieFile, setSelfieFile] = useState(null); + const [avatarStyle, setAvatarStyle] = useState(''); + const [failedStyles, setFailedStyles] = useState([]); + + /* + Mirrors AVATAR_STYLES in functions/coach-avatar-generator/avatar-generation.js. + The generator ignores anything not in that list and silently falls back to + every style, so these strings have to match it exactly — including + "Disney Charactor", which is misspelled in the PhotoMaker model itself. + */ const avatarStyles = [ - { value: 'realistic', label: 'Realistic Photo' }, - { value: 'professional', label: 'Professional Headshot' }, - { value: 'cartoon', label: 'Cartoon Style' }, - { value: 'fitness', label: 'Fitness Coach' }, - { value: 'athletic', label: 'Athletic Portrait' } + 'Realistic', 'Cinematic', 'Disney Charactor', 'Fantasy art', 'Enhance', + 'Comic book', 'Line art', 'Digital Art', 'Neonpunk', 'Photographic', 'Lowpoly', ]; useEffect(() => { @@ -41,9 +47,11 @@ const CoachAvatarEdit = () => { if (error) throw error; setCoach(coachData); - - // Set up default prompt based on coach data - setAvatarPrompt(`A ${coachData.primary_response_style || 'motivational'} fitness coach`); + /* + No default prompt. It is optional steering on top of the style's own + prompt now, and the old default ("A ... fitness coach") both assumed the + fitness-era product and overrode the style the creator had picked. + */ } catch (error) { console.error('Error fetching coach:', error); toast.error('Failed to load coach data'); @@ -106,37 +114,70 @@ const CoachAvatarEdit = () => { } }; + /** Reads a File into the bare base64 the generator's JSON path expects. */ + const readAsBase64 = (file) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result).replace(/^data:[^;]+;base64,/, '')); + reader.onerror = () => reject(reader.error ?? new Error('Could not read the image')); + reader.readAsDataURL(file); + }); + + /* + Generation is PhotoMaker, which builds a likeness from a photo — it cannot + invent a face from a description alone. So a selfie is required, and the + prompt only steers the result. This used to POST {prompt, style, count} to + `/motivational-images` and expect {images: []}; that endpoint is a Cloud + Scheduler background job with no HTTP contract at all, so the button could + never have worked. See #22. + */ const generateAvatars = async () => { - if (!avatarPrompt.trim()) { - toast.error('Please enter a description for your avatar'); + if (!selfieFile) { + toast.error('Choose a photo to generate from'); return; } try { setGenerating(true); - - const response = await fetch(`${import.meta.env.VITE_API_URL}/motivational-images`, { + setFailedStyles([]); + + // Same override the coach-builder flow honours; without it this component + // would break in any deploy that points the generator at its own URL. + const generatorUrl = + import.meta.env.VITE_COACH_AVATAR_GENERATOR_URL || + `${import.meta.env.VITE_API_URL}/coach-avatar-generator`; + + const response = await fetch(generatorUrl, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - prompt: avatarPrompt, - style: 'portrait', - count: 4 // Generate 4 options - }) + coachId, + selfie_base64: await readAsBase64(selfieFile), + selfie_mime: selfieFile.type || 'image/jpeg', + // Omitted means "every style", which is what the generator does. + ...(avatarStyle ? { style: avatarStyle } : {}), + ...(avatarPrompt.trim() ? { prompt: avatarPrompt.trim() } : {}), + }), }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { - throw new Error('Failed to generate avatars'); + // The generator explains itself; passing that through beats a generic toast. + throw new Error(data.error || `Avatar generation failed (${response.status})`); } - const data = await response.json(); - setGeneratedAvatars(data.images || []); - toast.success('Avatars generated successfully!'); + setGeneratedAvatars(data.avatars || []); + setFailedStyles(data.failedStyles || []); + + if ((data.avatars || []).length === 0) { + toast.error('No avatars came back. Try a clearer, front-facing photo.'); + } else { + toast.success(data.message || `Generated ${data.avatars.length} avatars`); + } } catch (error) { console.error('Error generating avatars:', error); - toast.error('Failed to generate avatars'); + toast.error(error.message || 'Failed to generate avatars'); } finally { setGenerating(false); } @@ -304,26 +345,61 @@ const CoachAvatarEdit = () => { {generateMethod === 'generate' && (

Generate Avatar

- +

+ Generation works from a photo — it restyles a real face rather than + inventing one, so a clear front-facing picture gives the best result. +

+
+ setSelfieFile(e.target.files?.[0] ?? null)} + disabled={generating} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> + {selfieFile && ( +

Using {selfieFile.name}

+ )} +
+ +
+ + +
+ +
+