diff --git a/.gitignore b/.gitignore index 6135369..0c634c4 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,8 @@ Thumbs.db # Output folders output/ + +.gemini/ +gha-creds-*.json +GEMINI.md +plans/ diff --git a/api/app/main.py b/api/app/main.py index c07fb7b..af26ed3 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -19,6 +19,7 @@ from .models import AvatarCreate, AvatarUpdate, ApiResponse, AgentRequest, AgentResponse, GenerateAvatarResponse from . import database as db +from . import onboarding # Add image_gen to path for importing pipeline IMAGE_GEN_PATH = Path(__file__).parent.parent.parent / "image_gen" @@ -58,6 +59,8 @@ async def lifespan(app: FastAPI): allow_headers=["*"], ) +app.include_router(onboarding.router) + # ============================================================================ # ROUTES # ============================================================================ diff --git a/api/app/models.py b/api/app/models.py index 077ffda..147ef84 100644 --- a/api/app/models.py +++ b/api/app/models.py @@ -68,3 +68,25 @@ class GenerateAvatarResponse(BaseModel): message: Optional[str] = None error: Optional[str] = None images: Optional[dict[str, str]] = None # {front: url, back: url, left: url, right: url} + + +class OnboardingChatRequest(BaseModel): + message: str + conversation_id: Optional[str] = None + + +class OnboardingChatResponse(BaseModel): + response: str + conversation_id: str + status: str = "active" # "active" or "completed" + + +class OnboardingStateResponse(BaseModel): + history: list[dict] + conversation_id: Optional[str] + is_completed: bool + + +class OnboardingCompleteRequest(BaseModel): + conversation_id: str + diff --git a/api/app/onboarding.py b/api/app/onboarding.py new file mode 100644 index 0000000..839c10b --- /dev/null +++ b/api/app/onboarding.py @@ -0,0 +1,283 @@ +import json +import os +from pathlib import Path +from typing import Optional, List +from fastapi import APIRouter, HTTPException, Depends, Request +from pydantic import BaseModel +from openai import OpenAI + +from .models import OnboardingChatRequest, OnboardingChatResponse, OnboardingStateResponse, OnboardingCompleteRequest +from .supabase_client import supabase + +router = APIRouter(prefix="/onboarding", tags=["onboarding"]) + +# Load questions +QUESTIONS_PATH = Path(__file__).parent.parent / "data" / "questions.json" +try: + with open(QUESTIONS_PATH, "r") as f: + QUESTIONS = json.load(f) +except Exception as e: + print(f"Error loading questions: {e}") + QUESTIONS = [] + +OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY") +if not OPENROUTER_API_KEY: + print("Warning: OPENROUTER_API_KEY not set. Onboarding chat will fail.") + +client = None +if OPENROUTER_API_KEY: + try: + client = OpenAI( + base_url="https://openrouter.ai/api/v1", + api_key=OPENROUTER_API_KEY, + ) + except Exception as e: + print(f"Failed to init OpenAI/OpenRouter client: {e}") + +MODEL_NAME = "xiaomi/mimo-v2-flash:free" + +async def get_current_user(request: Request): + auth_header = request.headers.get("Authorization") + if not auth_header: + raise HTTPException(status_code=401, detail="Missing Authorization header") + + token = auth_header.replace("Bearer ", "") + try: + user_response = supabase.auth.get_user(token) + if not user_response.user: + raise HTTPException(status_code=401, detail="Invalid token") + return user_response.user + except Exception as e: + print(f"Auth error: {e}") + raise HTTPException(status_code=401, detail="Invalid authentication") + +@router.get("/state", response_model=OnboardingStateResponse) +async def get_onboarding_state(user = Depends(get_current_user)): + # Find active onboarding conversation + response = supabase.table("conversations")\ + .select("*")\ + .eq("participant_a", user.id)\ + .eq("is_onboarding", True)\ + .order("created_at", desc=True)\ + .limit(1)\ + .execute() + + if response.data: + conv = response.data[0] + return { + "history": conv.get("transcript", []), + "conversation_id": conv["id"], + "is_completed": False + } + + return { + "history": [], + "conversation_id": None, + "is_completed": False + } + +@router.post("/chat", response_model=OnboardingChatResponse) +async def chat_onboarding(req: OnboardingChatRequest, user = Depends(get_current_user)): + if not client: + raise HTTPException(status_code=503, detail="AI service unavailable") + + conversation_id = req.conversation_id + transcript = [] + + # 1. Retrieve or Create Conversation + if conversation_id: + res = supabase.table("conversations").select("*").eq("id", conversation_id).single().execute() + if not res.data: + raise HTTPException(status_code=404, detail="Conversation not found") + if res.data["participant_a"] != user.id: + raise HTTPException(status_code=403, detail="Not your conversation") + transcript = res.data.get("transcript", []) + else: + res = supabase.table("conversations")\ + .select("*")\ + .eq("participant_a", user.id)\ + .eq("is_onboarding", True)\ + .order("created_at", desc=True)\ + .limit(1)\ + .execute() + + if res.data: + conv = res.data[0] + conversation_id = conv["id"] + transcript = conv.get("transcript", []) + else: + new_conv = supabase.table("conversations").insert({ + "participant_a": user.id, + "is_onboarding": True, + "transcript": [] + }).execute() + conversation_id = new_conv.data[0]["id"] + transcript = [] + + # 2. Append User Message + if req.message != "[START]": + user_msg_obj = {"role": "user", "content": req.message} + transcript.append(user_msg_obj) + + # 3. Construct LLM Prompt + system_instruction = f""" + You are a friendly, casual interviewer for a virtual world called 'Avatar World'. + Your goal is to welcome the new user and get to know them by getting answers to the following questions. + + REQUIRED QUESTIONS: + {json.dumps(QUESTIONS, indent=2)} + + INSTRUCTIONS: + 1. Ask these questions ONE BY ONE. Do not dump them all at once. + 2. Maintain a conversational flow. React to their answers (e.g., "Oh, that's cool!", "I love pizza too!"). + 3. You can change the order if it flows better, but ensure all are covered eventually. + 4. Keep your responses concise (1-2 sentences usually). + 5. If the user asks you questions, answer briefly and steer back to the interview. + 6. When you are satisfied that you have answers to ALL specific questions (or the user has declined to answer enough times), + you MUST signal completion by calling the 'end_interview' tool. + + Current Progress: + Review the transcript below. See which questions have been answered. Ask the next one. + """ + + messages = [{"role": "system", "content": system_instruction}] + # Append transcript messages + # Ensure roles are 'user' or 'assistant'. OpenRouter/OpenAI expects 'assistant' not 'model'. + for msg in transcript: + # My transcript uses 'assistant' internally, so it's fine. + messages.append(msg) + + # Define the tool + tools = [ + { + "type": "function", + "function": { + "name": "end_interview", + "description": "Call this when all questions have been answered to finish the onboarding.", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } + } + ] + + try: + completion = client.chat.completions.create( + model=MODEL_NAME, + messages=messages, + tools=tools, + tool_choice="auto" + ) + except Exception as e: + print(f"OpenRouter API Error: {e}") + return OnboardingChatResponse( + response="I'm having a bit of trouble connecting to my brain right now. Can you say that again?", + conversation_id=conversation_id, + status="active" + ) + + # 5. Process Response + ai_text = "" + status = "active" + + response_message = completion.choices[0].message + + # Check for tool calls + if response_message.tool_calls: + # Check if it's the right tool + for tool_call in response_message.tool_calls: + if tool_call.function.name == "end_interview": + status = "completed" + ai_text = "Thanks! That's everything I needed. Enjoy the world!" + break + + if status != "completed": + ai_text = response_message.content or "Hmm, I didn't catch that." + + # 6. Save AI Response + ai_msg_obj = {"role": "assistant", "content": ai_text} + transcript.append(ai_msg_obj) + + supabase.table("conversations").update({ + "transcript": transcript, + "updated_at": "now()" + }).eq("id", conversation_id).execute() + + return OnboardingChatResponse( + response=ai_text, + conversation_id=conversation_id, + status=status + ) + +@router.post("/complete") +async def complete_onboarding(req: OnboardingCompleteRequest, user = Depends(get_current_user)): + if not client: + raise HTTPException(status_code=503, detail="AI service unavailable") + + # 1. Fetch Transcript + res = supabase.table("conversations").select("*").eq("id", req.conversation_id).single().execute() + if not res.data: + raise HTTPException(status_code=404, detail="Conversation not found") + + conversation = res.data + transcript = conversation.get("transcript", []) + + # 2. Generate Memory Summary + summary_prompt = f""" + Analyze the following onboarding transcript for user '{user.id}'. + + Transcript: + {json.dumps(transcript)} + + Task: + 1. Extract key facts (Name, Job, Hobbies, etc.). + 2. Analyze their speaking style (Formal/Casual, Emoji usage, Length). + 3. Create a concise summary paragraph. + + Output JSON: + {{ + "facts": {{ ... }}, + "style": "...", + "summary": "..." + }} + """ + + try: + completion = client.chat.completions.create( + model=MODEL_NAME, + messages=[ + {"role": "system", "content": "You are a helpful assistant that outputs JSON."}, + {"role": "user", "content": summary_prompt} + ], + response_format={"type": "json_object"} + ) + content = completion.choices[0].message.content + summary_data = json.loads(content) + summary_text = summary_data.get("summary", "New user joined the world.") + except Exception as e: + print(f"Summary generation failed: {e}") + summary_text = "User completed onboarding." + + # 3. Save Memory + supabase.table("memories").insert({ + "conversation_id": req.conversation_id, + "owner_id": user.id, + "partner_id": None, + "summary": summary_text, + "conversation_score": 10 + }).execute() + + # 4. Update User Metadata + try: + supabase.auth.admin.update_user_by_id( + user.id, + {"user_metadata": {"onboarding_completed": True}} + ) + except Exception as e: + print(f"Failed to update user metadata: {e}") + # Note: If service key is invalid/missing rights, this fails. + raise HTTPException(status_code=500, detail="Failed to finalize onboarding.") + + return {"ok": True} \ No newline at end of file diff --git a/api/app/supabase_client.py b/api/app/supabase_client.py new file mode 100644 index 0000000..ce0f74d --- /dev/null +++ b/api/app/supabase_client.py @@ -0,0 +1,19 @@ +import os +from typing import Optional +from dotenv import load_dotenv +from supabase import create_client, Client + +load_dotenv() + +SUPABASE_URL = os.getenv("SUPABASE_URL") +SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_KEY") + +supabase: Optional[Client] = None + +if SUPABASE_URL and SUPABASE_SERVICE_KEY: + try: + supabase = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY) + except Exception as e: + print(f"Failed to initialize Supabase client: {e}") +else: + print("Warning: SUPABASE_URL or SUPABASE_SERVICE_KEY not set.") diff --git a/api/data/questions.json b/api/data/questions.json new file mode 100644 index 0000000..5e524ed --- /dev/null +++ b/api/data/questions.json @@ -0,0 +1,12 @@ +[ + "What is your name?", + "Where are you from?", + "What do you do for work or study?", + "What are your main hobbies or interests?", + "What kind of music do you like?", + "Do you have a favorite movie or TV show?", + "What brings you to this virtual world today?", + "If you could have any superpower, what would it be?", + "What's your favorite food?", + "Is there anything else you'd like to share about yourself?" +] diff --git a/api/debug/reset_user.py b/api/debug/reset_user.py new file mode 100644 index 0000000..0209025 --- /dev/null +++ b/api/debug/reset_user.py @@ -0,0 +1,76 @@ +import os +import sys +from dotenv import load_dotenv +from supabase import create_client + +# Load env vars +load_dotenv() + +SUPABASE_URL = os.getenv("SUPABASE_URL") +SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_KEY") + +if not SUPABASE_URL or not SUPABASE_SERVICE_KEY: + print("Error: SUPABASE_URL or SUPABASE_SERVICE_KEY not set in .env") + sys.exit(1) + +supabase = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY) + +def reset_user(email: str): + print(f"Resetting onboarding for {email}...") + + # 1. Get User ID + # Admin list_users is the way, but might be slow if many users. + # Alternative: Sign in as them? No. + # We can query user_positions if it exists to get ID, or just iterate. + # Actually, supabase-py admin client usually has `list_users`. + + try: + # Note: listing users might be paginated. + # Ideally we'd have a get_user_by_email admin function but it's not always exposed in py client. + # Let's try to query our `user_positions` table first as a shortcut if they have a position. + + # Strategy A: Use RPC if available? No. + # Strategy B: List users (limit 100) and find email. + + users_response = supabase.auth.admin.list_users() + target_user = None + for u in users_response: + if u.email == email: + target_user = u + break + + if not target_user: + print(f"User {email} not found in first page of users.") + return + + user_id = target_user.id + print(f"Found User ID: {user_id}") + + # 2. Delete Conversations + # is_onboarding = true AND participant_a = user_id + res = supabase.table("conversations").delete().eq("participant_a", user_id).eq("is_onboarding", True).execute() + print(f"Deleted {len(res.data)} onboarding conversations.") + + # 3. Delete Memories (Optional, but good for clean slate) + # owner_id = user_id + res_mem = supabase.table("memories").delete().eq("owner_id", user_id).execute() + print(f"Deleted {len(res_mem.data)} memories.") + + # 4. Reset Metadata + supabase.auth.admin.update_user_by_id( + user_id, + {"user_metadata": {"onboarding_completed": False}} + ) + print("Reset 'onboarding_completed' to False.") + + print("------------------------------------------------") + print("✅ Reset Complete! You can now log in and restart onboarding.") + + except Exception as e: + print(f"Error: {e}") + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python reset_user.py ") + else: + reset_user(sys.argv[1]) diff --git a/api/test_onboarding.py b/api/test_onboarding.py new file mode 100644 index 0000000..252a945 --- /dev/null +++ b/api/test_onboarding.py @@ -0,0 +1,43 @@ +import os +from fastapi.testclient import TestClient +from unittest.mock import patch, MagicMock +from app.main import app + +# Mock dependencies +# We mock Supabase to avoid real DB calls and Auth checks for the unit test +# But to test integration, we might want real DB calls. +# Given the user wants to test "it works", they probably mean "end-to-end". +# But without a valid JWT, we can't hit the endpoint unless we mock `get_current_user`. + +def test_onboarding_chat_flow(): + # Mock the user dependency + mock_user = MagicMock() + mock_user.id = "test-user-id" + + # We need to override the dependency + from app.onboarding import get_current_user + app.dependency_overrides[get_current_user] = lambda: mock_user + + client = TestClient(app) + + # 1. Get State (should be empty initially or mocked) + # We need to mock supabase calls inside onboarding.py if we don't have a real DB running + # OR we rely on the real DB if the user has it set up. + # Let's assume the user has the DB set up. + + # We can't easily mock the internal supabase client without patching. + # Let's try to hit the endpoint and see if we get 401 (auth) or 200 (if we override auth). + + print("Testing /onboarding/chat...") + response = client.post( + "/onboarding/chat", + json={"message": "Hello, I am ready.", "conversation_id": None} + ) + + if response.status_code == 200: + print("Success! Response:", response.json()) + else: + print(f"Failed: {response.status_code} - {response.text}") + +if __name__ == "__main__": + test_onboarding_chat_flow() diff --git a/supabase/migrations/008_add_conversations_memories.sql b/supabase/migrations/008_add_conversations_memories.sql new file mode 100644 index 0000000..2599054 --- /dev/null +++ b/supabase/migrations/008_add_conversations_memories.sql @@ -0,0 +1,53 @@ +-- Create Conversations Table +CREATE TABLE conversations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + participant_a UUID REFERENCES auth.users(id) ON DELETE CASCADE, + participant_b UUID REFERENCES auth.users(id) ON DELETE CASCADE, -- NULL for System/AI + transcript JSONB DEFAULT '[]'::jsonb, + is_onboarding BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Create Memories Table +CREATE TABLE memories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE, + owner_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, -- NULL for System's memory + partner_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, + summary TEXT, + conversation_score INTEGER CHECK (conversation_score BETWEEN 1 AND 10), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Indexing for AI "Recollection" +CREATE INDEX idx_memories_owner_partner ON memories (owner_id, partner_id); + +-- Enable RLS +ALTER TABLE conversations ENABLE ROW LEVEL SECURITY; +ALTER TABLE memories ENABLE ROW LEVEL SECURITY; + +-- Policies for Conversations +-- Users can read conversations they are part of +CREATE POLICY "Users can read own conversations" ON conversations + FOR SELECT USING (auth.uid() = participant_a OR auth.uid() = participant_b); + +-- Users can insert conversations if they are participant_a +CREATE POLICY "Users can insert own conversations" ON conversations + FOR INSERT WITH CHECK (auth.uid() = participant_a); + +-- Users can update conversations they are part of (e.g. appending messages) +CREATE POLICY "Users can update own conversations" ON conversations + FOR UPDATE USING (auth.uid() = participant_a OR auth.uid() = participant_b); + +-- Policies for Memories +-- Users can read their own memories +CREATE POLICY "Users can read own memories" ON memories + FOR SELECT USING (auth.uid() = owner_id); + +-- Service role has full access +CREATE POLICY "Service role full access conversations" ON conversations + FOR ALL USING (auth.role() = 'service_role'); + +CREATE POLICY "Service role full access memories" ON memories + FOR ALL USING (auth.role() = 'service_role'); diff --git a/web/package-lock.json b/web/package-lock.json index bc32fa2..66771fa 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -68,6 +68,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1324,6 +1325,7 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1490,6 +1492,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1903,6 +1906,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -2147,6 +2151,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2316,6 +2321,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2328,6 +2334,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -2669,6 +2676,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2766,6 +2774,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/web/src/App.tsx b/web/src/App.tsx index ad0a53e..36efd15 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,17 +1,28 @@ -import { Routes, Route, Link, Navigate } from 'react-router-dom' +import { Routes, Route, Link, Navigate, useLocation } from 'react-router-dom' import { useAuth } from './contexts/AuthContext' import GameView from './pages/GameView' import WatchView from './pages/WatchView' import CreateAvatar from './pages/CreateAvatar' +import Onboarding from './pages/Onboarding' import Profile from './pages/Profile' import Login from './pages/Login' -function ProtectedRoute({ children, requireAvatar = false }: { children: React.ReactNode, requireAvatar?: boolean }) { - const { user, loading, hasAvatar, checkingAvatar } = useAuth() +import Header from './components/Header' + +function ProtectedRoute({ + children, + requireAvatar = false, + requireOnboarding = false +}: { + children: React.ReactNode, + requireAvatar?: boolean, + requireOnboarding?: boolean +}) { + const { user, loading, hasAvatar, checkingAvatar, onboardingCompleted } = useAuth() if (loading || checkingAvatar) { return ( -
+
Loading...
) @@ -25,64 +36,38 @@ function ProtectedRoute({ children, requireAvatar = false }: { children: React.R if (requireAvatar && hasAvatar === false) { return } + + // If route requires onboarding and user hasn't finished, redirect to onboarding + if (requireOnboarding && !onboardingCompleted) { + return + } return <>{children} } -export default function App() { +function AppContent() { const { user, signOut, loading, hasAvatar } = useAuth() + const location = useLocation() + const hideHeader = location.pathname === '/onboarding' || location.pathname === '/create' return ( -
- -
+
+ {!hideHeader &&
} +
} /> } /> - } /> + } /> } /> } /> + } /> } />
) } + +export default function App() { + return +} diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx new file mode 100644 index 0000000..b7e6845 --- /dev/null +++ b/web/src/components/Header.tsx @@ -0,0 +1,59 @@ +import { Link } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext' + +export default function Header() { + const { user, signOut, loading, hasAvatar } = useAuth() + + return ( + + ) +} diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 34b8bf5..fc3b4b6 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -7,6 +7,7 @@ interface AuthContextType { session: Session | null loading: boolean hasAvatar: boolean | null + onboardingCompleted: boolean checkingAvatar: boolean signIn: (email: string, password: string) => Promise<{ error: Error | null }> signUp: (email: string, password: string) => Promise<{ error: Error | null; isNewUser?: boolean }> @@ -33,6 +34,7 @@ export function AuthProvider({ children }: AuthProviderProps) { const [session, setSession] = useState(null) const [loading, setLoading] = useState(true) const [hasAvatar, setHasAvatar] = useState(null) + const [onboardingCompleted, setOnboardingCompleted] = useState(false) const [checkingAvatar, setCheckingAvatar] = useState(false) const checkAvatarStatus = async (userId: string) => { @@ -65,6 +67,12 @@ export function AuthProvider({ children }: AuthProviderProps) { const refreshAvatarStatus = async () => { if (user) { + // Refresh user metadata + const { data: { user: refreshedUser } } = await supabase.auth.getUser() + if (refreshedUser) { + setUser(refreshedUser) + setOnboardingCompleted(refreshedUser.user_metadata?.onboarding_completed === true) + } await checkAvatarStatus(user.id) } } @@ -75,6 +83,7 @@ export function AuthProvider({ children }: AuthProviderProps) { setSession(session) setUser(session?.user ?? null) if (session?.user) { + setOnboardingCompleted(session.user.user_metadata?.onboarding_completed === true) checkAvatarStatus(session.user.id) } setLoading(false) @@ -85,9 +94,11 @@ export function AuthProvider({ children }: AuthProviderProps) { setSession(session) setUser(session?.user ?? null) if (session?.user) { + setOnboardingCompleted(session.user.user_metadata?.onboarding_completed === true) checkAvatarStatus(session.user.id) } else { setHasAvatar(null) + setOnboardingCompleted(false) } setLoading(false) }) @@ -108,6 +119,7 @@ export function AuthProvider({ children }: AuthProviderProps) { const signOut = async () => { await supabase.auth.signOut() setHasAvatar(null) + setOnboardingCompleted(false) } return ( @@ -115,7 +127,8 @@ export function AuthProvider({ children }: AuthProviderProps) { user, session, loading, - hasAvatar, + hasAvatar, + onboardingCompleted, checkingAvatar, signIn, signUp, diff --git a/web/src/pages/CreateAvatar.tsx b/web/src/pages/CreateAvatar.tsx index 3a8f20f..1042166 100644 --- a/web/src/pages/CreateAvatar.tsx +++ b/web/src/pages/CreateAvatar.tsx @@ -14,15 +14,19 @@ interface GeneratedSprites { } export default function CreateAvatar() { - const { user, hasAvatar, refreshAvatarStatus } = useAuth() + const { user, hasAvatar, onboardingCompleted, refreshAvatarStatus } = useAuth() const navigate = useNavigate() - // If user already has avatar, redirect to play + // If user already has avatar, redirect to appropriate next step useEffect(() => { if (hasAvatar) { - navigate('/play') + if (onboardingCompleted) { + navigate('/play') + } else { + navigate('/onboarding') + } } - }, [hasAvatar, navigate]) + }, [hasAvatar, onboardingCompleted, navigate]) // Form state const [displayName, setDisplayName] = useState('') @@ -110,9 +114,9 @@ export default function CreateAvatar() { setStep('complete') - // Redirect to play after a short delay + // Redirect to onboarding after a short delay setTimeout(() => { - navigate('/play') + navigate('/onboarding') }, 1500) } catch (err) { console.error('Save avatar error:', err) @@ -130,7 +134,7 @@ export default function CreateAvatar() { // Input Step if (step === 'input') { return ( -
+

Create Your Avatar

@@ -214,7 +218,7 @@ export default function CreateAvatar() { // Generating Step if (step === 'generating') { return ( -

+

Creating Your Avatar

@@ -232,7 +236,7 @@ export default function CreateAvatar() { const directions: Array<'front' | 'back' | 'left' | 'right'> = ['front', 'back', 'left', 'right'] return ( -
+

Your Avatar is Ready!

@@ -328,7 +332,7 @@ export default function CreateAvatar() { // Saving Step if (step === 'saving') { return ( -

+

Saving your avatar...

@@ -340,7 +344,7 @@ export default function CreateAvatar() { // Complete Step if (step === 'complete') { return ( -
+
🎉

Avatar Created!

diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index 1769ffa..a2200b8 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -10,6 +10,14 @@ export default function Login() { const [loading, setLoading] = useState(false) const { signIn, signUp, user, hasAvatar, checkingAvatar } = useAuth() const navigate = useNavigate() + + // Check for mode=signup query param + useEffect(() => { + const params = new URLSearchParams(window.location.search) + if (params.get('mode') === 'signup') { + setIsSignUp(true) + } + }, []) // Redirect if user is already logged in useEffect(() => { @@ -51,7 +59,7 @@ export default function Login() { } return ( -
+

{isSignUp ? 'Join Avatar World' : 'Welcome Back'} diff --git a/web/src/pages/Onboarding.tsx b/web/src/pages/Onboarding.tsx new file mode 100644 index 0000000..5b5ab3e --- /dev/null +++ b/web/src/pages/Onboarding.tsx @@ -0,0 +1,219 @@ +import { useState, useEffect, useRef } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext' +import { API_CONFIG } from '../config' + +interface Message { + role: 'user' | 'assistant' + content: string +} + +export default function Onboarding() { + const { user, onboardingCompleted, refreshAvatarStatus, session } = useAuth() + const navigate = useNavigate() + const [messages, setMessages] = useState([]) + const [inputText, setInputText] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [isCompleting, setIsCompleting] = useState(false) + const [conversationId, setConversationId] = useState(null) + const messagesEndRef = useRef(null) + + useEffect(() => { + if (onboardingCompleted) { + navigate('/play') + } + }, [onboardingCompleted, navigate]) + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + + useEffect(() => { + scrollToBottom() + }, [messages]) + + // Initialize chat + useEffect(() => { + const initChat = async () => { + if (!session?.access_token) return + + try { + // 1. Get State + const res = await fetch(`${API_CONFIG.BASE_URL}/onboarding/state`, { + headers: { + 'Authorization': `Bearer ${session.access_token}` + } + }) + const data = await res.json() + + if (data.conversation_id) { + setConversationId(data.conversation_id) + setMessages(data.history.map((m: any) => ({ + role: m.role === 'model' ? 'assistant' : m.role, // Handle Gemini role mapping if needed, but backend saves 'assistant' + content: m.content + }))) + } + + // If history is empty, start conversation + if (!data.history || data.history.length === 0) { + await sendMessage("[START]", true) + } + } catch (err) { + console.error("Failed to init chat:", err) + } + } + + initChat() + }, [session]) + + const sendMessage = async (text: string, isHidden: boolean = false) => { + if (!text.trim() || !session?.access_token) return + + if (!isHidden) { + setMessages(prev => [...prev, { role: 'user', content: text }]) + } + + setInputText('') + setIsLoading(true) + + try { + const res = await fetch(`${API_CONFIG.BASE_URL}/onboarding/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.access_token}` + }, + body: JSON.stringify({ + message: text, + conversation_id: conversationId + }) + }) + + const data = await res.json() + setConversationId(data.conversation_id) + + if (data.status === 'completed') { + // Handle completion + setMessages(prev => [...prev, { role: 'assistant', content: data.response }]) + await handleCompletion(data.conversation_id) + } else { + setMessages(prev => [...prev, { role: 'assistant', content: data.response }]) + } + } catch (err) { + console.error("Chat error:", err) + } finally { + setIsLoading(false) + } + } + + const handleCompletion = async (convId: string) => { + setIsCompleting(true) + try { + const res = await fetch(`${API_CONFIG.BASE_URL}/onboarding/complete`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session?.access_token}` + }, + body: JSON.stringify({ conversation_id: convId }) + }) + + if (res.ok) { + await refreshAvatarStatus() // Updates user metadata in context + setTimeout(() => { + navigate('/play') + }, 500) + } + } catch (err) { + console.error("Completion error:", err) + setIsCompleting(false) + } + } + + return ( +
+ {/* Header */} +
+
+
+
+ 🤖 +
+
+

World Greeter

+

Setting up your profile...

+
+
+ +
+
+ + {/* Messages */} +
+
+ {messages.map((msg, idx) => ( +
+
+ {msg.content} +
+
+ ))} + {isLoading && ( +
+
+
+
+
+
+
+ )} +
+
+
+ + {/* Input */} +
+
+ setInputText(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && !isLoading && sendMessage(inputText)} + placeholder="Type your answer..." + disabled={isLoading} + className="flex-1 bg-gray-900 border border-gray-700 rounded-full px-6 py-3 focus:outline-none focus:border-green-500 focus:ring-1 focus:ring-green-500 disabled:opacity-50" + /> + +
+
+
+ ) +} diff --git a/web/src/pages/Profile.tsx b/web/src/pages/Profile.tsx index 2c76366..5c6969b 100644 --- a/web/src/pages/Profile.tsx +++ b/web/src/pages/Profile.tsx @@ -212,7 +212,7 @@ export default function Profile() { if (loading) { return ( -
+
Loading profile...
) @@ -220,7 +220,7 @@ export default function Profile() { if (!profile?.has_avatar) { return ( -
+

No Avatar Yet

You haven't created an avatar yet. Create one to start playing!

@@ -236,7 +236,7 @@ export default function Profile() { } return ( -
+

Your Profile