diff --git a/.gitignore b/.gitignore index f8799e3..a10d01a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ .ipynb_checkpoints eval_results_python.csv .env +.env.local +/node_modules \ No newline at end of file diff --git a/16-matthew-mcconaughey/host-your-own/.gitignore b/16-matthew-mcconaughey/host-your-own/.gitignore new file mode 100644 index 0000000..ff5c616 --- /dev/null +++ b/16-matthew-mcconaughey/host-your-own/.gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# data files from notebook +/data + diff --git a/16-matthew-mcconaughey/host-your-own/README.md b/16-matthew-mcconaughey/host-your-own/README.md new file mode 100644 index 0000000..393cf76 --- /dev/null +++ b/16-matthew-mcconaughey/host-your-own/README.md @@ -0,0 +1,131 @@ +# Matthew McConaughey AI Chat + +Host the agent you just made w/Nextjs and Vercel! + +A clean, simple Next.js chat interface for interacting with a Contextual AI agent embodying Matthew McConaughey's wisdom, philosophy, and life lessons. + +## Features + +- Clean, modern chat interface +- Subtle space-themed design with McConaughey aesthetics +- Real-time conversation with AI agent +- Responsive design for mobile and desktop +- Powered by Contextual AI + +## Prerequisites + +- Node.js 18+ installed +- A Contextual AI API key +- Your Matthew McConaughey agent ID (from your notebook) + +## Setup + +1. **Install dependencies:** + ```bash + npm install + ``` + +2. **Set up environment variables:** + + Copy the example environment file: + ```bash + cp .env.local.example .env.local + ``` + + Edit `.env.local` and add your credentials: + ``` + CONTEXTUAL_API_KEY=your_api_key_here + CONTEXTUAL_AGENT_ID=your_agent_id_here + ``` + +3. **Find your Agent ID:** + + You can get your agent ID from your Jupyter notebook where you created the agent. Look for the output after running the agent creation code: + ```python + print(f"Agent ID created: {agent_id}") + ``` + + Or retrieve it by listing your agents: + ```python + agents = client.agents.list() + for agent in agents: + if agent.name == "Matthew_McConaughey": + print(f"Agent ID: {agent.id}") + ``` + +## Running the App + +Start the development server: + +```bash +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000) in your browser. + +## Building for Production + +Build the application: + +```bash +npm run build +``` + +Start the production server: + +```bash +npm start +``` + +## Deployment + +This Next.js app can be easily deployed to: + +- **Vercel** (recommended): Connect your GitHub repo at [vercel.com](https://vercel.com) +- **Netlify**: Follow their Next.js deployment guide +- **Any Node.js hosting**: Run `npm run build` and `npm start` + +Don't forget to set your environment variables (`CONTEXTUAL_API_KEY` and `CONTEXTUAL_AGENT_ID`) in your deployment platform's settings. + +## Project Structure + +``` +├── app/ +│ ├── api/ +│ │ └── chat/ +│ │ └── route.ts # API endpoint for Contextual AI +│ ├── globals.css # Global styles and animations +│ ├── layout.tsx # Root layout +│ └── page.tsx # Main chat interface +├── .env.local.example # Environment variables template +├── next.config.js # Next.js configuration +├── tailwind.config.js # Tailwind CSS configuration +└── package.json # Dependencies and scripts +``` + +## Customization + +- **Colors**: Edit `tailwind.config.js` to change the color scheme +- **Suggested queries**: Update the `suggestedQueries` array in `app/page.tsx` +- **Styling**: Modify `app/globals.css` and component styles in `app/page.tsx` + +## Troubleshooting + +**"API key not configured" error:** +- Make sure you've created `.env.local` from `.env.local.example` +- Verify your API key is correct +- Restart the development server after adding environment variables + +**"Agent ID not configured" error:** +- Add your agent ID to `.env.local` +- Make sure the agent exists in your Contextual AI account + +**No response from agent:** +- Check your API key has the correct permissions +- Verify your agent is properly configured with a datastore +- Check the browser console and terminal for error messages + +## License + +MIT + diff --git a/16-matthew-mcconaughey/host-your-own/app/api/chat/route.ts b/16-matthew-mcconaughey/host-your-own/app/api/chat/route.ts new file mode 100644 index 0000000..ccaeee7 --- /dev/null +++ b/16-matthew-mcconaughey/host-your-own/app/api/chat/route.ts @@ -0,0 +1,183 @@ +import { NextResponse } from 'next/server' +import ContextualAI from 'contextual-client' + +interface Message { + role: 'user' | 'assistant' + content: string +} + +export async function POST(request: Request) { + try { + const { messages } = await request.json() + + if (!messages || messages.length === 0) { + return NextResponse.json( + { error: 'No messages provided' }, + { status: 400 } + ) + } + + const apiKey = process.env.CONTEXTUAL_API_KEY + const agentId = process.env.CONTEXTUAL_AGENT_ID + + if (!apiKey) { + return NextResponse.json( + { error: 'API key not configured. Please set CONTEXTUAL_API_KEY in .env.local' }, + { status: 500 } + ) + } + + if (!agentId) { + return NextResponse.json( + { error: 'Agent ID not configured. Please set CONTEXTUAL_AGENT_ID in .env.local' }, + { status: 500 } + ) + } + + const encoder = new TextEncoder() + + const sseStream = new ReadableStream({ + start: async (controller) => { + const encodeSSE = (data: string) => encoder.encode(`data: ${data}\n\n`) + const encodeComment = (comment: string) => encoder.encode(`: ${comment}\n\n`) + + controller.enqueue(encodeComment('stream-start')) + + let heartbeat: NodeJS.Timeout | null = null + const startHeartbeat = () => { + heartbeat = setInterval(() => { + try { + controller.enqueue(encodeComment('keep-alive')) + } catch (_) { + } + }, 15000) + } + + const stopHeartbeat = () => { + if (heartbeat) { + clearInterval(heartbeat) + heartbeat = null + } + } + + try { + let observedMessageId: string | null = null + const observedContentIds: string[] = [] + let observeBuffer = '' + const observeChunk = (text: string) => { + observeBuffer += text + if (observeBuffer.includes('\r')) observeBuffer = observeBuffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + while (true) { + const sep = observeBuffer.indexOf('\n\n') + if (sep === -1) break + const raw = observeBuffer.slice(0, sep) + observeBuffer = observeBuffer.slice(sep + 2) + const lines = raw.split('\n') + if (lines.every(l => l.startsWith(':'))) continue + const dataPayload = lines.filter(l => l.startsWith('data:')).map(l => l.slice(5).trimStart()).join('\n') + if (!dataPayload) continue + try { + const evt = JSON.parse(dataPayload) + if (evt?.event === 'metadata') { + if (evt.data?.message_id) observedMessageId = evt.data.message_id + } else if (evt?.event === 'retrievals') { + const contents = evt.data?.contents || [] + for (const c of contents) { + const cid = c?.content_id + if (cid && !observedContentIds.includes(cid)) observedContentIds.push(cid) + } + } + } catch (_) { + + } + } + } + + const upstream = await fetch(`https://api.contextual.ai/v1/agents/${agentId}/query?include_retrieval_content_text=true`, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + messages: messages.map((msg: Message) => ({ role: msg.role, content: msg.content })), + stream: true, + }), + }) + + if (!upstream.ok || !upstream.body) { + const text = await upstream.text().catch(() => '') + const errMsg = `Upstream error ${upstream.status}: ${text}` + console.error('[ctxl-stream] upstream failed:', errMsg) + controller.enqueue(encodeSSE(JSON.stringify({ error: errMsg }))) + controller.close() + stopHeartbeat() + return + } + + startHeartbeat() + + const reader = upstream.body.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + if (value) { + controller.enqueue(value) + try { + const chunkStr = new TextDecoder().decode(value) + observeChunk(chunkStr) + } catch (_) { + + } + } + } + + try { + if (observedMessageId && observedContentIds.length > 0) { + const client = new ContextualAI({ apiKey }) + const retrievalInfo = await client.agents.query.retrievalInfo( + agentId, + observedMessageId, + { content_ids: observedContentIds } + ) + const contentMetadatas = retrievalInfo?.content_metadatas || [] + controller.enqueue(encodeSSE(JSON.stringify({ event: 'content_metadatas', data: { content_metadatas: contentMetadatas } }))) + } + } catch (e) { + console.error('[ctxl-stream] retrievalInfo failed:', e) + } + + controller.enqueue(encodeComment('stream-end')) + controller.close() + stopHeartbeat() + } catch (err: any) { + console.error('[ctxl-stream] error:', err?.message || err) + try { + controller.enqueue(encodeSSE(JSON.stringify({ error: String(err?.message || err) }))) + } finally { + controller.close() + stopHeartbeat() + } + } + }, + cancel: () => { + }, + }) + + return new Response(sseStream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }) + } catch (error: any) { + console.error('Error in chat API:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} + diff --git a/16-matthew-mcconaughey/host-your-own/app/api/retrieval-info/route.ts b/16-matthew-mcconaughey/host-your-own/app/api/retrieval-info/route.ts new file mode 100644 index 0000000..15150d0 --- /dev/null +++ b/16-matthew-mcconaughey/host-your-own/app/api/retrieval-info/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from 'next/server' +import ContextualAI from 'contextual-client' + +export async function POST(request: Request) { + try { + const { messageId, contentIds } = await request.json() + + if (!messageId) { + return NextResponse.json( + { error: 'Message ID is required' }, + { status: 400 } + ) + } + + if (!contentIds || !Array.isArray(contentIds) || contentIds.length === 0) { + return NextResponse.json( + { error: 'Content IDs are required' }, + { status: 400 } + ) + } + + const apiKey = process.env.CONTEXTUAL_API_KEY + const agentId = process.env.CONTEXTUAL_AGENT_ID + + if (!apiKey) { + return NextResponse.json( + { error: 'API key not configured. Please set CONTEXTUAL_API_KEY in .env.local' }, + { status: 500 } + ) + } + + if (!agentId) { + return NextResponse.json( + { error: 'Agent ID not configured. Please set CONTEXTUAL_AGENT_ID in .env.local' }, + { status: 500 } + ) + } + + const client = new ContextualAI({ + apiKey: apiKey, + }) + + // Fetch detailed retrieval info + const retrievalInfo = await client.agents.query.retrievalInfo( + agentId, + messageId, + { content_ids: contentIds } + ) + + const contentMetadatas = retrievalInfo.content_metadatas || [] + + return NextResponse.json({ + contentMetadatas + }) + } catch (error: any) { + console.error('Error fetching retrieval info:', error) + + if (error instanceof ContextualAI.APIError) { + return NextResponse.json( + { error: `API Error: ${error.message}` }, + { status: error.status || 500 } + ) + } + + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} + diff --git a/16-matthew-mcconaughey/host-your-own/app/globals.css b/16-matthew-mcconaughey/host-your-own/app/globals.css new file mode 100644 index 0000000..01f2114 --- /dev/null +++ b/16-matthew-mcconaughey/host-your-own/app/globals.css @@ -0,0 +1,69 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + @apply bg-[#0b0c10] text-white; + font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + min-height: 100vh; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); +} + +/* Starfield background */ +.starfield { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 0; + overflow: hidden; + pointer-events: none; + transform: translate3d(0, 0, 0); + will-change: transform; + contain: strict; +} + +.star { + position: absolute; + width: 2px; + height: 2px; + background: white; + border-radius: 50%; + opacity: 0.5; +} + +/* Message animations */ +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.message-enter { + animation: slideIn 0.3s ease-out; +} + diff --git a/16-matthew-mcconaughey/host-your-own/app/layout.tsx b/16-matthew-mcconaughey/host-your-own/app/layout.tsx new file mode 100644 index 0000000..0b7d965 --- /dev/null +++ b/16-matthew-mcconaughey/host-your-own/app/layout.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from 'next' +import { Analytics } from '@vercel/analytics/next'; +import './globals.css' + +export const metadata: Metadata = { + title: 'Matthew McConaughey AI', + description: 'Chat with an AI embodying Matthew McConaughey\'s wisdom and philosophy', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + + + + + + {children} + + + + ) +} + diff --git a/16-matthew-mcconaughey/host-your-own/app/page.tsx b/16-matthew-mcconaughey/host-your-own/app/page.tsx new file mode 100644 index 0000000..92069be --- /dev/null +++ b/16-matthew-mcconaughey/host-your-own/app/page.tsx @@ -0,0 +1,846 @@ +'use client' + +import { useState, useRef, useEffect } from 'react' + +interface Message { + role: 'user' | 'assistant' + content: string + retrievalContents?: any[] + detailedRetrievals?: any[] + messageId?: string +} + +export default function Home() { + const [messages, setMessages] = useState([]) + const [input, setInput] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [loadingMessageIndex, setLoadingMessageIndex] = useState(0) + const [hasPlayedMusic, setHasPlayedMusic] = useState(false) + const [isMusicPlaying, setIsMusicPlaying] = useState(false) + const [selectedCitation, setSelectedCitation] = useState(null) + const [showCitationModal, setShowCitationModal] = useState(false) + const [isTweetExpanded, setIsTweetExpanded] = useState(false) + const messagesEndRef = useRef(null) + const audioRef = useRef(null) + const assistantIndexRef = useRef(null) + const lastRenderedRef = useRef('') + + // Generate stars ONCE and never again + const stars = useRef( + Array.from({ length: 100 }).map(() => ({ + left: Math.random() * 100, + top: Math.random() * 100, + })) + ).current + + const loadingMessages = [ + "Alright, alright, alright... just a second", + "Looking through the Interstellar files", + "Checking my journal entries", + "Searching through Greenlights", + "Finding that Texas wisdom", + "Consulting the stars", + "Just keep livin', give me a moment", + "Time is a flat circle... finding your answer", + ] + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + + useEffect(() => { + scrollToBottom() + }, [messages]) + + useEffect(() => { + if (isLoading) { + const interval = setInterval(() => { + setLoadingMessageIndex((prev) => (prev + 1) % loadingMessages.length) + }, 2000) // Change message every 2 seconds + + return () => clearInterval(interval) + } + }, [isLoading, loadingMessages.length]) + + // Try to play music on mount (might fail due to browser autoplay policy) + useEffect(() => { + if (audioRef.current) { + audioRef.current.volume = 0.3 + audioRef.current.play() + .then(() => { + setHasPlayedMusic(true) + setIsMusicPlaying(true) + console.log('Music auto-started') + }) + .catch(() => { + // Autoplay blocked - will start on first user interaction + console.log('Autoplay blocked - music will start on first interaction') + }) + } + }, []) + + const startMusicIfNeeded = () => { + if (!hasPlayedMusic && audioRef.current) { + audioRef.current.volume = 0.3 + audioRef.current.play() + .then(() => { + setHasPlayedMusic(true) + setIsMusicPlaying(true) + console.log('Music started playing') + }) + .catch(err => console.error('Audio play failed:', err)) + } + } + + // Process message content to add clickable citations + const processMessageWithCitations = (content: string, retrievalContents: any[] = []) => { + if (!retrievalContents || retrievalContents.length === 0) { + return content + } + + // First, clean up markdown-style citations [1]()() or [1]() -> [1] + // This matches [number] followed by one or more ()() pairs + let cleanContent = content.replace(/\[(\d+)\](\(\))+/g, '[$1]') + + // Then make citation numbers clickable + return cleanContent.replace(/\[(\d+)\]/g, (match, citationNumber) => { + const citationIndex = parseInt(citationNumber) - 1 + if (citationIndex >= 0 && citationIndex < retrievalContents.length) { + return `${match}` + } + return match + }) + } + + const handleCitationClick = async (citationIndex: number, message: Message) => { + const hasDetails = !!(message.detailedRetrievals && message.detailedRetrievals[citationIndex]) + const base = hasDetails ? message.detailedRetrievals![citationIndex] : {} + + const initialSelected = { + ...base, + citationNumber: citationIndex + 1, + retrievalContent: message.retrievalContents?.[citationIndex], + } + setSelectedCitation(initialSelected) + setShowCitationModal(true) + + // If page_img already present, no need to fetch more + if ((base as any)?.page_img) return + + try { + const content = message.retrievalContents?.[citationIndex] + const contentId = content?.content_id + const msgId = message.messageId + if (!contentId || !msgId) return + + const resp = await fetch('/api/retrieval-info', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messageId: msgId, contentIds: [contentId] }), + }) + if (!resp.ok) return + const data = await resp.json() + const meta = (data?.contentMetadatas && data.contentMetadatas[0]) || null + const pageImg = meta?.page_img || null + const contentText = content?.content_text || '' + + if (pageImg) { + // Update selected citation with the fetched page image + setSelectedCitation((prev: any) => { + if (!prev) return prev + return { + ...prev, + page_img: pageImg, + content_text: prev.content_text || contentText, + } + }) + } + } catch (e) { + console.error('Failed to fetch citation image', e) + } + } + + const closeCitationModal = () => { + setShowCitationModal(false) + setSelectedCitation(null) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!input.trim() || isLoading) return + + startMusicIfNeeded() + + const userMessage: Message = { role: 'user', content: input } + const updatedMessages = [...messages, userMessage] + setMessages(updatedMessages) + setInput('') + setIsLoading(true) + assistantIndexRef.current = null + lastRenderedRef.current = '' + + try { + const response = await fetch('/api/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + messages: updatedMessages.map(msg => ({ + role: msg.role, + content: msg.content + })), + }), + }) + + if (!response.ok || !response.body) { + throw new Error('Network error') + } + + // Parse SSE stream + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let streamedContent = '' + let retrievalContents: any[] = [] + let messageId = '' + let conversationId = '' + + // Reset streamedContent and ensure refs are clean + streamedContent = '' + assistantIndexRef.current = null + lastRenderedRef.current = '' + + while (true) { + const { value, done } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + // Normalize CRLF to LF to ensure consistent event splitting + if (buffer.includes('\r')) buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + + // Process complete SSE events separated by blank line + while (true) { + const lfSep = buffer.indexOf('\n\n') + const crlfSep = buffer.indexOf('\r\n\r\n') + let sep = -1 + let sepLen = 2 + if (lfSep !== -1 && crlfSep !== -1) { + sep = Math.min(lfSep, crlfSep) + sepLen = sep === lfSep ? 2 : 4 + } else if (lfSep !== -1) { + sep = lfSep + sepLen = 2 + } else if (crlfSep !== -1) { + sep = crlfSep + sepLen = 4 + } + if (sep === -1) break + const raw = buffer.slice(0, sep) + buffer = buffer.slice(sep + sepLen) + + const lines = raw.split('\n') + if (lines.every(l => l.startsWith(':'))) continue + + const dataPayload = lines + .filter(l => l.startsWith('data:')) + .map(l => l.slice(5).trimStart()) + .join('\n') + if (!dataPayload) continue + + let evt + try { + evt = JSON.parse(dataPayload) + } catch { + continue + } + + // Handle events + switch (evt.event) { + case 'metadata': + if (evt.data?.conversation_id) conversationId = evt.data.conversation_id + if (evt.data?.message_id) messageId = evt.data.message_id + break + case 'retrievals': + if (evt.data?.contents) { + retrievalContents = evt.data.contents + } + break + case 'message_delta': + if (evt.data?.delta) { + streamedContent += evt.data.delta + + if (assistantIndexRef.current === null) { + assistantIndexRef.current = -1 // Temporary marker to prevent duplicate insertion + setIsLoading(false) + + lastRenderedRef.current = streamedContent + + const placeholderRetrievals = retrievalContents.map((content: any) => ({ + content_text: content.content_text || '', + page_img: null, + retrievalContent: content, + })) + + setMessages(prev => { + const index = prev.length + assistantIndexRef.current = index + return [ + ...prev, + { + role: 'assistant', + content: streamedContent, + retrievalContents, + detailedRetrievals: placeholderRetrievals, + messageId, + } + ] + }) + } else { + // Subsequent deltas update the existing assistant message + if (streamedContent.length <= lastRenderedRef.current.length || + streamedContent === lastRenderedRef.current) { + continue + } + + lastRenderedRef.current = streamedContent + setMessages(prev => { + if (assistantIndexRef.current === null || + assistantIndexRef.current < 0 || + assistantIndexRef.current >= prev.length) { + return prev + } + const updated = [...prev] + const idx = assistantIndexRef.current + + if (updated[idx].content.length >= streamedContent.length && + updated[idx].content === streamedContent) { + return prev + } + + updated[idx] = { + ...updated[idx], + content: streamedContent, + retrievalContents, + messageId, + } + return updated + }) + } + } + break + case 'message_complete': + if (evt.data?.final_message) { + streamedContent = evt.data.final_message + } + break + case 'attributions': + // Attributions are already in the message via citations + break + case 'content_metadatas': { + const contentMetadatas = evt.data?.content_metadatas || [] + const metadataMap = new Map() + let useIndexMatching = true + contentMetadatas.forEach((meta: any) => { + if (meta.content_id) { + metadataMap.set(meta.content_id, meta) + useIndexMatching = false + } + }) + const merged = retrievalContents.map((content: any, index: number) => { + const meta = useIndexMatching ? (contentMetadatas[index] || {}) : (metadataMap.get(content.content_id) || {}) + return { + content_text: content?.content_text || '', + page_img: meta?.page_img || null, + retrievalContent: content, + } + }) + if (assistantIndexRef.current !== null) { + setMessages(prev => { + if (assistantIndexRef.current === null || assistantIndexRef.current >= prev.length) return prev + const updated = [...prev] + const idx = assistantIndexRef.current + updated[idx] = { + ...updated[idx], + detailedRetrievals: merged, + retrievalContents, + messageId, + } + return updated + }) + } + break + } + case 'end': + break + case 'error': + throw new Error(evt.data?.message || 'Stream error') + default: + break + } + } + } + + setIsLoading(false) + + // Fetch detailed retrieval info with page_img + let detailedRetrievals: any[] = [] + if (messageId && retrievalContents.length > 0) { + try { + const contentIds = retrievalContents.map((content: any) => content.content_id).filter(Boolean) + if (contentIds.length > 0) { + const retrievalInfoResponse = await fetch('/api/retrieval-info', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + messageId, + contentIds, + }), + }) + + if (retrievalInfoResponse.ok) { + const retrievalInfoData = await retrievalInfoResponse.json() + const contentMetadatas = retrievalInfoData.contentMetadatas || [] + + // Merge contentMetadatas (page_img) with retrievalContents + const metadataMap = new Map() + let useIndexMatching = true + contentMetadatas.forEach((meta: any, idx: number) => { + if (meta.content_id) { + metadataMap.set(meta.content_id, meta) + useIndexMatching = false + } + }) + + detailedRetrievals = retrievalContents.map((content: any, index: number) => { + const metadata = useIndexMatching + ? (contentMetadatas[index] || {}) + : (metadataMap.get(content.content_id) || {}) + return { + content_text: content.content_text || '', + page_img: metadata.page_img || null, + retrievalContent: content, + } + }) + } + } + } catch (error) { + console.error('Error fetching detailed retrieval info:', error) + } + } + + if (detailedRetrievals.length === 0) { + detailedRetrievals = retrievalContents.map((content: any) => ({ + content_text: content.content_text || '', + page_img: null, + retrievalContent: content, + })) + } + + setMessages(prev => { + if (assistantIndexRef.current === null) { + const index = prev.length + assistantIndexRef.current = index + lastRenderedRef.current = streamedContent + return [ + ...prev, + { + role: 'assistant', + content: streamedContent, + retrievalContents, + detailedRetrievals, + messageId, + } + ] + } + if (assistantIndexRef.current < 0 || assistantIndexRef.current >= prev.length) return prev + const updated = [...prev] + const idx = assistantIndexRef.current + + if (updated[idx].content.length >= streamedContent.length && + updated[idx].content === streamedContent) { + return prev + } + + lastRenderedRef.current = streamedContent + updated[idx] = { + ...updated[idx], + content: streamedContent, + retrievalContents, + detailedRetrievals, + messageId, + } + return updated + }) + + } catch (error) { + setMessages(prev => [...prev, { + role: 'assistant', + content: 'Sorry, something went wrong. Please try again.', + }]) + } finally { + setIsLoading(false) + } + } + + const suggestedQueries = [ + "What's one bold, 'unrealistic' goal you have written down?", + "What are your thoughts on having a personal LLM?", + "Tell me the story behind 'alright, alright, alright'", + ] + + const handleSuggestedQuery = (query: string) => { + startMusicIfNeeded() + setInput(query) + } + + const toggleMusic = () => { + if (audioRef.current) { + if (isMusicPlaying) { + audioRef.current.pause() + setIsMusicPlaying(false) + } else { + audioRef.current.volume = 0.3 + audioRef.current.play() + .then(() => { + setHasPlayedMusic(true) + setIsMusicPlaying(true) + }) + .catch(err => console.error('Audio play failed:', err)) + } + } + } + + return ( +
+ {/* Starfield background */} +
+ {stars.map((star, i) => ( +
+ ))} +
+ + {/* Audio element */} +