diff --git a/docs/PRD.md b/docs/PRD.md index e8a71ac..a96bf08 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1,12 +1,14 @@ # Sona — Product Requirements Document -**Version:** 1.1 +**Version:** 1.3 **Status:** Active **Last Updated:** April 2026 **Author:** Liam Buckman **Changelog:** - v1.1: Removed audio features (Spotify deprecated Nov 2024); added Last.fm genre integration; updated Spotify field changes from Feb 2026 API changelog; noted Spotify development mode 25-user limit +- v1.2: Removed recently played; added guest AI interaction and rate limiting; updated goals and success metrics framing +- v1.3: Corrected Sprint 3 status — chat implemented as context-dump + streaming (not agentic tool use); agentic tool use deferred to post-v1; updated architecture summary and open questions accordingly --- @@ -40,15 +42,15 @@ There is no tool that uses AI to make listening data feel personal, expressive, - Deliver a polished, production-grade full-stack web application that solves a real user need and is publicly accessible - Implement Spotify OAuth 2.0 (Authorization Code Flow) with secure, encrypted token management - Surface key listening stats (top tracks, top artists, genre breakdown) in a clean, accessible portfolio-style layout where Sona's AI voice narrates each section -- Integrate an LLM (Claude Haiku 4.5) with an agentic tool-use architecture for the chat interface, enabling Claude to dynamically decide which user data to fetch based on the question +- Integrate an LLM (Claude Haiku 4.5) for personalized music insights, a music personality profile, and a conversational chat interface grounded in the user's Spotify data - Allow unauthenticated (guest) users to interact with Sona's AI on the landing page with general music knowledge — no sign-in required unless personal data or account actions are needed - Deploy publicly on Vercel with a live URL ### Secondary Goals - Demonstrate exemplary repository practices: conventional commits, CI/CD via GitHub Actions, typed schema, Zod validation, documented architecture in `docs/` -- Serve as a learning vehicle for: Spotify OAuth, relational database design, LLM API integration, agentic AI patterns, prompt engineering, rate limiting, and caching strategies -- Build a foundation extensible to future features (playlist generation, social listening, shareable public profiles) +- Serve as a learning vehicle for: Spotify OAuth, relational database design, LLM API integration, prompt engineering, SSE streaming, rate limiting, and caching strategies +- Build a foundation extensible to future features (agentic tool use, playlist generation, social listening, shareable public profiles) --- @@ -57,6 +59,7 @@ There is no tool that uses AI to make listening data feel personal, expressive, - **No music playback** — Sona surfaces insights about music, it does not play it - **No recently played data** — omitted to reduce API surface and token cost; Spotify natively displays this for users - **No audio features** — Spotify deprecated this endpoint for new apps in November 2024; genre signals are derived from Last.fm instead +- **No agentic tool use in v1** — the chat interface uses a pre-built context dump rather than dynamic tool calls; true agentic tool use is a post-v1 enhancement (see Section 12) - **No social features in v1** — friend comparisons, shared profiles, and listening rooms are post-v1 - **No Apple Music support** — Spotify-only for initial release - **No mobile app** — web-only; responsive design supports mobile browsers @@ -101,15 +104,16 @@ There is no tool that uses AI to make listening data feel personal, expressive, #### F-03 · Protected Route Layout ✅ -- All `/profile` and `/chat` routes require an active session +- All `/profile` routes require an active session - Unauthenticated users are redirected to `/` - Authenticated users visiting `/` are redirected to `/profile` +- `/chat` is accessible to guests with reduced functionality (general music intelligence only) #### F-04 · Landing Page ✅ (placeholder — full design Sprint 4) --- -### Sprint 2 — Core Stats Data Layer ✅ (in progress) +### Sprint 2 — Core Stats Data Layer ✅ **Goal:** Authenticated users have real Spotify data available via typed, cached API routes. @@ -140,58 +144,67 @@ There is no tool that uses AI to make listening data feel personal, expressive, - `tracks` field renamed to `items` in Spotify's Feb 2026 changelog — updated - Cached in DB (30-minute TTL) -#### F-09 · Guest AI Interaction (Landing Page) — Sprint 3 - -- Landing page hero input functional for unauthenticated users -- General music intelligence responses (no personal Spotify data) -- Contextual sign-in prompt when personal data is needed - -#### F-10 · Profile Page UI — in progress +#### F-09 · Profile Page UI ✅ - TanStack Query provider and hooks -- Portfolio layout with real data sections +- Portfolio layout with Artists, Genre, Tracks, and Playlists sections +- Skeleton loading states per section +- Sticky nav with section anchors --- -### Sprint 3 — Sona AI Layer +### Sprint 3 — Sona AI Layer ✅ -**Goal:** Transform the stats portfolio into a genuinely intelligent experience. +**Goal:** Transform the stats portfolio into a genuinely intelligent experience using Claude Haiku 4.5. -#### F-11 · Sona Voice (Inline AI Narration) +#### F-10 · Sona Insights Card ✅ -- Each portfolio section opens with a short Sona-written observation -- Generated from real user data; cached per section per day -- Tone: second person, specific, grounded in data, never generic +- Featured AI-generated 2–4 sentence daily insight at the top of the profile page +- Synthesizes top artists, genre breakdown, and listening patterns via pre-built context +- Cached per user per day (24-hour TTL in `ai_cache`) +- Skeleton loading state while generating -#### F-12 · Sona Insights Card +#### F-11 · Sona Profile (Music Personality) ✅ -- Featured AI-generated summary at top of profile page, streamed live -- Synthesizes top artists, genre breakdown, and listening patterns -- Cached per user per day (24-hour TTL) +- Full AI-generated music personality profile (3–5 sentences) +- Assigns a named listening archetype (e.g. "The Devoted Follower") +- Cached with 7-day TTL; expires automatically +- Rendered in Sona's italic serif voice -#### F-13 · Sona Profile (Music Personality) +#### F-12 · Ask Sona — Chat Interface ✅ -- Full AI-generated music personality profile -- Assigns a personality archetype -- Regeneratable once per 7 days; cached with 7-day TTL +- Dedicated `/chat` page accessible to both authenticated and guest users +- **Implementation note:** Chat uses a pre-built context dump approach — the user's full listening context (top tracks, top artists, genre breakdown) is assembled server-side and passed as a system prompt to Claude. Claude does not dynamically call tools to fetch data per-question in v1. +- Streaming responses via Server-Sent Events (SSE) — responses appear word-by-word in real time +- Multi-turn conversation history maintained client-side (server is stateless) +- Authenticated users receive personalized responses grounded in their Spotify data +- Guest users receive general music intelligence responses (no personal data) +- Input validation: message capped at 4,000 characters, history capped at 12 turns +- Suggested prompts shown before first message -#### F-14 · Sona Moods (Playlist Analysis) +#### F-13 · Sona Voice Prompt System ✅ -- Classify playlists by mood using AI interpretation of names, track counts, and genre context -- Display as mood-tagged playlist cards +- Shared prompt templates enforcing Sona's tone: second person, data-grounded, slightly poetic +- Context wrapped in `` delimiters to prevent prompt injection +- Genre breakdown capped at 8 entries before serialization +- `server-only` guard on Anthropic client -#### F-15 · Ask Sona — Agentic Chat Interface +#### F-14 · Guest AI Interaction ✅ (partial) -- Dedicated `/chat` page with conversational interface -- Claude uses tool use to fetch only the data needed per question -- Available tools: `get_top_tracks`, `get_top_artists`, `get_genre_breakdown`, `get_playlist_moods` -- Floating shortcut button on profile page -- Guest version on landing page with general (non-personal) responses +- Chat page accessible without sign-in +- General music intelligence responses for guests +- Full landing page guest experience deferred to Sprint 4 --- ### Sprint 4 — Polish, A11y & Deployment +#### F-15 · Landing Page with Guest AI Hero + +- Suno-style hero input on landing page +- Guest can ask a music question and receive an AI response without signing in +- Contextual "Connect Spotify" prompt when personal data would improve the answer + #### F-16 · Guest Rate Limiting - 10 AI requests per hour per IP via Upstash Redis at the Vercel Edge layer @@ -199,22 +212,23 @@ There is no tool that uses AI to make listening data feel personal, expressive, #### F-17 · Responsive Design - All views functional on mobile (≥ 375px), tablet (≥ 768px), and desktop +- Profile sections reflow gracefully at small widths #### F-18 · Accessibility (A11y) - Keyboard navigation, semantic HTML, ARIA labels, WCAG 2.1 AA contrast - No motion for `prefers-reduced-motion` +- Screen reader testing on key flows #### F-19 · Loading, Error & Empty States -- Skeleton loading states for all data-fetching components -- User-friendly error messages with retry -- Graceful empty states for first-time users +- User-friendly error messages with retry on all data sections +- Graceful empty states for first-time users with sparse data #### F-20 · Vercel Deployment ✅ - Live at `https://sonamusic.vercel.app` -- Preview deployments on all PRs +- Preview deployments enabled for all PRs #### F-21 · README & Documentation ✅ @@ -231,18 +245,18 @@ User Browser ▼ Next.js 16 (App Router) — Vercel │ - ├── / (landing) Public — guest AI interaction + ├── / (landing) Public — guest AI interaction (Sprint 4) ├── /profile Protected — portfolio layout, AI narration - ├── /chat Protected — agentic Ask Sona interface + ├── /chat Public — chat (personalized if auth'd, general if guest) ├── /api/auth/* OAuth routes ├── /api/spotify/* Spotify data proxy (cached) ├── /api/lastfm/* Last.fm genre data (cached) - └── /api/ai/* AI generation + agentic chat + └── /api/ai/* AI generation + context-dump chat │ - ├── Vercel Edge Middleware Guest rate limiting (Upstash Redis) + ├── Vercel Edge Middleware Guest rate limiting (Upstash Redis) — Sprint 4 ├── Spotify Web API OAuth 2.0 + top tracks/artists/playlists ├── Last.fm API Artist genre tags (artist.getTopTags) - ├── Anthropic API Claude Haiku 4.5 with tool use + ├── Anthropic API Claude Haiku 4.5 (context-dump, streaming SSE) └── Neon Postgres Users, tokens, Spotify cache, AI cache └── Drizzle ORM Type-safe queries + versioned migrations ``` @@ -310,7 +324,7 @@ ai_cache **Product quality** - All Sprint 1–3 features complete and functional with real Spotify data -- Guest AI interaction works without sign-in; rate limiting is enforced +- Guest chat works without sign-in; rate limiting enforced in Sprint 4 - Application loads within acceptable performance budgets - Zero TypeScript errors or console errors in production build - WCAG 2.1 AA accessibility compliance on all primary views @@ -318,7 +332,7 @@ ai_cache **Engineering quality** - CI pipeline is green on `main` with every commit -- Codebase demonstrates: OAuth 2.0, AES-256-GCM encryption, DB caching with multiple TTLs, agentic LLM integration, multi-API orchestration, TypeScript strict mode, and edge rate limiting +- Codebase demonstrates: OAuth 2.0, AES-256-GCM encryption, DB caching with multiple TTLs, LLM integration with streaming SSE, multi-API orchestration, TypeScript strict mode, input validation, and edge rate limiting - Conventional commit history is clean and readable as a project narrative - `docs/` folder contains up-to-date PRD and system design document @@ -326,21 +340,22 @@ ai_cache ## 11. Sprint Timeline -| Sprint | Focus | Status | -| -------- | ----------------------------------- | -------------- | -| Sprint 1 | Foundation, Auth, DB schema | ✅ Complete | -| Sprint 2 | Core Stats + Data Layer | 🔄 In Progress | -| Sprint 3 | Sona AI Layer | Upcoming | -| Sprint 4 | Polish, A11y, Rate Limiting, Deploy | Upcoming | +| Sprint | Focus | Status | +| -------- | ----------------------------------------- | ----------- | +| Sprint 1 | Foundation, Auth, DB schema | ✅ Complete | +| Sprint 2 | Core Stats + Data Layer + Profile UI | ✅ Complete | +| Sprint 3 | Sona AI Layer | ✅ Complete | +| Sprint 4 | Landing Page, Polish, A11y, Rate Limiting | 🔄 Next | --- ## 12. Post-v1 Roadmap +- **Agentic tool use for chat** — upgrade Ask Sona so Claude dynamically decides which data to fetch per question via tool calls (`get_top_tracks`, `get_top_artists`, `get_genre_breakdown`, `get_playlist_moods`), rather than receiving a full pre-built context dump. More efficient, more capable, better interview story. - **Shareable public profile** (`/u/username`) — public URL for your music identity - **Playlist generation with Spotify import** — AI-generated playlists pushed to Spotify - **Social listening rooms** — real-time WebSocket sessions with AI commentary -- **Persistent chat history** — store conversations across sessions +- **Persistent chat history** — store conversations across sessions in the database - **Apple Music support** — extend data pipeline beyond Spotify - **Spotify quota extension** — apply for Extended Quota Mode to remove 25-user limit @@ -348,7 +363,8 @@ ai_cache ## 13. Open Questions -- [ ] Should guest AI responses use a lighter model than authenticated responses? -- [ ] Should chat history be sent as part of the tool-use message array or maintained separately? -- [ ] Should the public profile page (post-v1) require explicit opt-in from the user? +- [ ] Should guest AI responses use a lighter/faster model than authenticated responses to reduce cost? +- [ ] When implementing agentic tool use (post-v1), should tool results be passed back into the message array or handled via a separate context assembly step? +- [ ] Should the public profile page (post-v1) require explicit opt-in from the user, or be opt-out? - [ ] When should we apply for Spotify quota extension — before or after v1.0 launch? +- [ ] Should the chat history cap (currently 12 turns) be user-configurable in a future version? diff --git a/src/app/(protected)/chat/page.tsx b/src/app/(protected)/chat/page.tsx new file mode 100644 index 0000000..f9e8168 --- /dev/null +++ b/src/app/(protected)/chat/page.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import Link from "next/link"; +import { SectionLabel } from "@/components/layout/section-label"; + +interface Message { + role: "user" | "assistant"; + content: string; +} + +const SUGGESTED_PROMPTS = [ + "What does my music say about me?", + "Which of my artists have I listened to the longest?", + "What mood does my listening suggest lately?", + "Why do I keep returning to the same artists?", +]; + +export default function ChatPage() { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [isStreaming, setIsStreaming] = useState(false); + const isStreamingRef = useRef(false); + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + async function sendMessage(text: string) { + if (!text.trim() || isStreamingRef.current) return; + + const userMessage: Message = { role: "user", content: text }; + const newHistory = [...messages, userMessage]; + setMessages(newHistory); + setInput(""); + isStreamingRef.current = true; + setIsStreaming(true); + + // Add empty assistant message to stream into + setMessages((prev) => [...prev, { role: "assistant", content: "" }]); + + try { + const res = await fetch("/api/ai/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: text, + history: messages, // send history before the new user message + }), + }); + + if (!res.ok) throw new Error("Chat request failed"); + if (!res.body) throw new Error("No response body"); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let streamDone = false; + + while (!streamDone) { + const { done, value } = await reader.read(); + if (done) { + buffer += decoder.decode(); + break; + } + + buffer += decoder.decode(value, { stream: true }); + const events = buffer.split("\n\n"); + buffer = events.pop() || ""; // last chunk may be incomplete + + for (const event of events) { + const line = event.split("\n").find((l) => l.startsWith("data: ")); + if (!line) continue; + + const data = line.slice(6); // remove "data: " prefix + if (data === "[DONE]") { + streamDone = true; + break; + } + + try { + const parsed = JSON.parse(data) as { text?: string; error?: string }; + if (parsed.error) { + throw new Error(parsed.error); + } + if (parsed.text) { + setMessages((prev) => { + const updated = [...prev]; + const last = updated[updated.length - 1]; + if (last?.role === "assistant") { + updated[updated.length - 1] = { + ...last, + content: last.content + parsed.text, + }; + } + return updated; + }); + } + } catch { + // Skip malformed chunks + } + } + } + } catch (error) { + console.error("Chat error:", error); + setMessages((prev) => { + const updated = [...prev]; + const last = updated[updated.length - 1]; + if (last?.role === "assistant" && last.content === "") { + updated[updated.length - 1] = { + ...last, + content: "Sorry, I ran into an issue. Please try again.", + }; + } + return updated; + }); + } finally { + isStreamingRef.current = false; + setIsStreaming(false); + } + } + + return ( +
+ {/* Nav */} +
+
+ + ← Back to profile + + sona + +
+ +
+ {/* Header */} +
+ AI Chat +

Ask Sona

+

+ Sona knows your listening history. Ask anything about your music. +

+
+ + {/* Suggested prompts — shown only before conversation starts */} + {messages.length === 0 && ( +
+ {SUGGESTED_PROMPTS.map((prompt) => ( + + ))} +
+ )} + + {/* Messages */} +
+ {messages.map((msg, i) => ( +
+
+ {msg.content} + {/* Typing cursor during streaming */} + {isStreaming && i === messages.length - 1 && msg.role === "assistant" && ( +
+
+ ))} +
+
+ + {/* Input */} +
+
+ setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + void sendMessage(input); + } + }} + placeholder="Ask Sona about your music..." + aria-label="Message to Sona" + disabled={isStreaming} + className="flex-1 bg-transparent px-3 py-2 text-sm outline-none placeholder:text-muted-foreground/50 disabled:cursor-not-allowed" + /> + +
+
+
+
+ ); +} diff --git a/src/app/(protected)/profile/page.tsx b/src/app/(protected)/profile/page.tsx index 4769d02..3a53f84 100644 --- a/src/app/(protected)/profile/page.tsx +++ b/src/app/(protected)/profile/page.tsx @@ -5,6 +5,8 @@ import { GenreSection } from "./_components/genre-section"; import { PlaylistsSection } from "./_components/playlists-section"; import { SectionLabel } from "@/components/layout/section-label"; import { SonaVoice } from "@/components/sona/sona-voice"; +import { InsightCard } from "@/components/sona/insight-card"; +import { SonaProfileCard } from "@/components/sona/sona-profile-card"; export default async function ProfilePage() { const session = await getSession(); @@ -32,7 +34,16 @@ export default async function ProfilePage() { Playlists + + Ask Sona + + + Ask Sona +