From 9f43bfb9df68ef72bd9afc0254532dc9626fd977 Mon Sep 17 00:00:00 2001 From: lebuckman Date: Sun, 19 Apr 2026 20:12:30 -0700 Subject: [PATCH 1/4] feat(ai): add AI layer with insights and profile - Add Anthropic client singleton with model and token limit constants - Add SonaUserContext builder reading from DB cache (no live API calls) - Add prompt templates for insight, profile, and chat with Sona voice guidelines - Add /api/ai/insights route with 24-hour DB cache - Add /api/ai/profile route with 7-day DB cache - Add /api/ai/chat route with SSE streaming and multi-turn history - Add useAiInsights and useAiProfile TanStack Query hooks - Add InsightCard component with loading skeleton - Add SonaProfileCard component with loading skeleton - Add Ask Sona chat page with real-time streaming and suggested prompts - Wire InsightCard and SonaProfileCard into profile page - Add Ask Sona nav link from profile to chat --- src/app/(protected)/chat/page.tsx | 222 ++++++++++++++++++++++ src/app/(protected)/profile/page.tsx | 11 +- src/app/api/ai/chat/route.ts | 106 +++++++++++ src/app/api/ai/insights/route.ts | 103 ++++++++++ src/app/api/ai/profile/route.ts | 94 +++++++++ src/components/sona/insight-card.tsx | 40 ++++ src/components/sona/sona-profile-card.tsx | 35 ++++ src/hooks/use-ai-insights.ts | 21 ++ src/hooks/use-ai-profile.ts | 21 ++ src/lib/ai/client.ts | 17 ++ src/lib/ai/context.ts | 105 ++++++++++ src/lib/ai/prompts.ts | 64 +++++++ 12 files changed, 838 insertions(+), 1 deletion(-) create mode 100644 src/app/(protected)/chat/page.tsx create mode 100644 src/app/api/ai/chat/route.ts create mode 100644 src/app/api/ai/insights/route.ts create mode 100644 src/app/api/ai/profile/route.ts create mode 100644 src/components/sona/insight-card.tsx create mode 100644 src/components/sona/sona-profile-card.tsx create mode 100644 src/hooks/use-ai-insights.ts create mode 100644 src/hooks/use-ai-profile.ts create mode 100644 src/lib/ai/client.ts create mode 100644 src/lib/ai/context.ts create mode 100644 src/lib/ai/prompts.ts diff --git a/src/app/(protected)/chat/page.tsx b/src/app/(protected)/chat/page.tsx new file mode 100644 index 0000000..a17a2bf --- /dev/null +++ b/src/app/(protected)/chat/page.tsx @@ -0,0 +1,222 @@ +"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 bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + async function sendMessage(text: string) { + if (!text.trim() || isStreaming) return; + + const userMessage: Message = { role: "user", content: text }; + const newHistory = [...messages, userMessage]; + setMessages(newHistory); + setInput(""); + 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(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value); + const lines = chunk.split("\n").filter((l) => l.startsWith("data: ")); + + for (const line of lines) { + const data = line.slice(6); // remove 'data: ' + if (data === "[DONE]") break; + + try { + const parsed = JSON.parse(data) as { text?: string; error?: string }; + 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 { + 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..9365e57 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,6 +34,9 @@ export default async function ProfilePage() { Playlists + + Ask Sona +