diff --git a/Ralph/prd.json b/Ralph/prd.json index bb6075c..e3b6677 100644 --- a/Ralph/prd.json +++ b/Ralph/prd.json @@ -175,7 +175,7 @@ "Typecheck passes" ], "priority": 9, - "passes": false, + "passes": true, "notes": "Gemini REST streaming endpoint: POST https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:streamGenerateContent?alt=sse&key=API_KEY. The response is SSE (server-sent events) — parse each 'data:' line as JSON and extract candidates[0].content.parts[0].text. The system prompt with CV context will be ~2-3K tokens — well within Gemini Flash limits. For the palette item IDs, instruct the model to end its response with a line like [ITEMS: id1, id2, id3] which can be parsed client-side." }, { diff --git a/Ralph/progress.txt b/Ralph/progress.txt index c6e590e..17ce355 100644 --- a/Ralph/progress.txt +++ b/Ralph/progress.txt @@ -19,6 +19,12 @@ - `prefersReducedMotion` pattern: read `window.matchMedia` at module level, use in framer-motion variants to skip animation - ChatWidget stores messages as `Array<{ role: 'user' | 'assistant', content: string }>` — same shape as LLM message format, ready for Gemini integration - ChatWidget `isOpen` state controls both panel visibility and button icon (MessageCircle ↔ X) — panel rendering handled by AnimatePresence +- `src/lib/gemini.ts` exports `sendChatMessage(messages)` (async generator), `isGeminiAvailable()`, `parseItemIds(text)`, `stripItemsSuffix(text)` — ChatMessage type is `{ role: 'user' | 'assistant', content: string }` +- Gemini API uses SSE streaming: POST to `:streamGenerateContent?alt=sse&key=KEY`, parse `data:` lines as JSON, extract `candidates[0].content.parts[0].text` +- System prompt built from `buildEmbeddingTexts()` — instructs model to end responses with `[ITEMS: id1, id2, id3]` for portfolio item linking +- `isGeminiAvailable()` checks `import.meta.env.VITE_GEMINI_API_KEY` — when missing, chat panel shows "unavailable" message but button remains visible +- Assistant messages store item IDs as `` HTML comment suffix for US-010 to parse — `getDisplayText()` strips this before rendering +- Conversation history capped at 10 messages (`MAX_HISTORY`), metadata stripped before sending to API --- @@ -165,3 +171,32 @@ - The `ChatMessage` interface (`{ role, content }`) is ready to be extended for US-009 Gemini integration — same shape as typical LLM message format - `onFocus/onBlur` border color transitions on the textarea give a polished input interaction --- + +## 2026-02-15 - US-009 +- Created `src/lib/gemini.ts` — Gemini Flash streaming integration module + - `sendChatMessage(messages)` async generator that streams SSE tokens from Gemini 2.0 Flash + - `isGeminiAvailable()` checks for `VITE_GEMINI_API_KEY` env var + - `parseItemIds(text)` extracts `[ITEMS: id1, id2]` from response text + - `stripItemsSuffix(text)` removes the `[ITEMS: ...]` line for clean display + - System prompt built from `buildEmbeddingTexts()` output — full CV context (~42 items) + - Model instructed to answer concisely and append relevant palette item IDs +- Rewired `ChatWidget.tsx` to use real Gemini API instead of placeholder responses + - Streaming: tokens progressively appear in assistant message bubble + - Typing indicator (Loader2 spinner + "Thinking...") shown while waiting for first token + - Input disabled during streaming, send button grayed out + - Error handling: API failures show "Sorry, I couldn't process that. Please try again." + - Missing API key: panel shows "Chat is currently unavailable", input area hidden + - Conversation history capped at 10 messages before sending to API + - Assistant messages store parsed item IDs as `` HTML comment (for US-010) + - Messages sent to API have metadata stripped to keep context clean +- Typecheck, lint (0 errors), and build all pass +- Files changed: `src/lib/gemini.ts` (new), `src/components/ChatWidget.tsx` +- **Learnings for future iterations:** + - Gemini SSE format: `data:` prefix per line, JSON body with `candidates[0].content.parts[0].text` + - `system_instruction` field in Gemini request body sets the system prompt (not a message in `contents`) + - Gemini role mapping: `'assistant'` → `'model'` in the API's `contents` array + - Buffer-based SSE parsing handles chunk boundaries: split on `\n`, keep last incomplete line in buffer + - `buildEmbeddingTexts()` is a great source for structured CV context — natural language paragraphs per item + - The `` HTML comment pattern is invisible when rendered but parseable by US-010 for item card display + - `useCallback` on `handleSubmit` with `[inputValue, isStreaming, messages]` deps is needed because it reads all three +--- diff --git a/src/components/ChatWidget.tsx b/src/components/ChatWidget.tsx index e0f3f11..5172b1e 100644 --- a/src/components/ChatWidget.tsx +++ b/src/components/ChatWidget.tsx @@ -1,13 +1,17 @@ -import { useState, useRef, useEffect, type KeyboardEvent } from 'react' +import { useState, useRef, useEffect, useCallback, type KeyboardEvent } from 'react' import { motion, AnimatePresence } from 'framer-motion' -import { MessageCircle, X, Send } from 'lucide-react' +import { MessageCircle, X, Send, Loader2 } from 'lucide-react' +import { + sendChatMessage, + isGeminiAvailable, + parseItemIds, + stripItemsSuffix, + type ChatMessage, +} from '@/lib/gemini' const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches -interface ChatMessage { - role: 'user' | 'assistant' - content: string -} +const MAX_HISTORY = 10 const buttonVariants = { hidden: prefersReducedMotion @@ -38,15 +42,16 @@ const panelVariants = { : { opacity: 0, scale: 0.95, transition: { duration: 0.15, ease: 'easeIn' } }, } -const PLACEHOLDER_RESPONSE = 'AI chat coming soon — this is a preview of the chat interface.' - export function ChatWidget() { const [isOpen, setIsOpen] = useState(false) const [messages, setMessages] = useState([]) const [inputValue, setInputValue] = useState('') + const [isStreaming, setIsStreaming] = useState(false) const messagesEndRef = useRef(null) const inputRef = useRef(null) + const geminiAvailable = isGeminiAvailable() + // Auto-scroll to latest message useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) @@ -59,17 +64,67 @@ export function ChatWidget() { } }, [isOpen]) - const handleSubmit = () => { + const handleSubmit = useCallback(async () => { const trimmed = inputValue.trim() - if (!trimmed) return + if (!trimmed || isStreaming) return - setMessages((prev) => [ - ...prev, - { role: 'user', content: trimmed }, - { role: 'assistant', content: PLACEHOLDER_RESPONSE }, - ]) + const userMessage: ChatMessage = { role: 'user', content: trimmed } + const updatedMessages = [...messages, userMessage] + + // Cap history to last MAX_HISTORY messages, strip internal metadata + const historyForApi = updatedMessages.slice(-MAX_HISTORY).map((msg) => ({ + ...msg, + content: msg.content.replace(/\n?/, '').trim(), + })) + + setMessages(updatedMessages) setInputValue('') - } + setIsStreaming(true) + + // Add empty assistant message that will be streamed into + const assistantMessage: ChatMessage = { role: 'assistant', content: '' } + setMessages((prev) => [...prev, assistantMessage]) + + try { + const stream = sendChatMessage(historyForApi) + let accumulated = '' + + for await (const chunk of stream) { + accumulated += chunk + // Update the last (assistant) message with accumulated text + setMessages((prev) => { + const updated = [...prev] + updated[updated.length - 1] = { role: 'assistant', content: accumulated } + return updated + }) + } + + // Final cleanup: strip [ITEMS: ...] suffix from display text (keep raw for parsing) + // We store the clean display text but parse items from the raw accumulated text + const cleanText = stripItemsSuffix(accumulated) + const itemIds = parseItemIds(accumulated) + const finalContent = itemIds.length > 0 + ? `${cleanText}\n` + : cleanText + + setMessages((prev) => { + const updated = [...prev] + updated[updated.length - 1] = { role: 'assistant', content: finalContent } + return updated + }) + } catch { + setMessages((prev) => { + const updated = [...prev] + updated[updated.length - 1] = { + role: 'assistant', + content: "Sorry, I couldn't process that. Please try again.", + } + return updated + }) + } finally { + setIsStreaming(false) + } + }, [inputValue, isStreaming, messages]) const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -78,6 +133,11 @@ export function ChatWidget() { } } + // Extract display text from message content (strip hidden item metadata) + const getDisplayText = (content: string) => { + return content.replace(/\n?/, '').trim() + } + return ( <> {/* Chat panel */} @@ -105,7 +165,6 @@ export function ChatWidget() { transformOrigin: 'bottom right', }} > - {/* Use inline style for sm-width via CSS class override */} ) } diff --git a/src/lib/gemini.ts b/src/lib/gemini.ts new file mode 100644 index 0000000..05f26a5 --- /dev/null +++ b/src/lib/gemini.ts @@ -0,0 +1,152 @@ +import { buildEmbeddingTexts } from '@/lib/search' + +export interface ChatMessage { + role: 'user' | 'assistant' + content: string +} + +const GEMINI_API_BASE = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash' + +function getApiKey(): string | undefined { + return import.meta.env.VITE_GEMINI_API_KEY as string | undefined +} + +export function isGeminiAvailable(): boolean { + return !!getApiKey() +} + +function buildSystemPrompt(): string { + const texts = buildEmbeddingTexts() + const cvContent = texts.map((t) => `- ${t.text}`).join('\n') + + return `You are an AI assistant embedded in Andy Charlwood's professional portfolio website. Your role is to answer questions about Andy's professional experience, skills, projects, and qualifications accurately and concisely. + +Here is Andy's complete professional profile: + +${cvContent} + +Instructions: +- Answer questions based ONLY on the information above. Do not invent roles, dates, or achievements. +- Be concise — 2-4 sentences for most answers. +- Be professional but friendly in tone. +- If asked something not covered by the profile data, say you don't have that information. +- At the end of your response, on a new line, include relevant portfolio item IDs in this format: [ITEMS: id1, id2, id3] +- Only include item IDs that are directly relevant to your answer. The available IDs are the ones listed above (e.g., exp-*, skill-*, proj-*, ach-*, edu-*, action-*). +- If no items are particularly relevant, omit the [ITEMS: ...] line entirely.` +} + +function buildRequestBody( + messages: ChatMessage[], + systemPrompt: string, +): object { + const contents = messages.map((msg) => ({ + role: msg.role === 'assistant' ? 'model' : 'user', + parts: [{ text: msg.content }], + })) + + return { + system_instruction: { + parts: [{ text: systemPrompt }], + }, + contents, + generationConfig: { + temperature: 0.7, + maxOutputTokens: 512, + }, + } +} + +export async function* sendChatMessage( + messages: ChatMessage[], +): AsyncGenerator { + const apiKey = getApiKey() + if (!apiKey) { + throw new Error('Gemini API key not configured') + } + + const systemPrompt = buildSystemPrompt() + const body = buildRequestBody(messages, systemPrompt) + + const response = await fetch( + `${GEMINI_API_BASE}:streamGenerateContent?alt=sse&key=${apiKey}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + ) + + if (!response.ok) { + throw new Error(`Gemini API error: ${response.status}`) + } + + const reader = response.body?.getReader() + if (!reader) { + throw new Error('No response body') + } + + const decoder = new TextDecoder() + let buffer = '' + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + + const lines = buffer.split('\n') + // Keep the last potentially incomplete line in the buffer + buffer = lines.pop() ?? '' + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed.startsWith('data:')) continue + + const jsonStr = trimmed.slice(5).trim() + if (!jsonStr || jsonStr === '[DONE]') continue + + try { + const parsed = JSON.parse(jsonStr) + const text = parsed?.candidates?.[0]?.content?.parts?.[0]?.text + if (text) { + yield text + } + } catch { + // Skip malformed JSON chunks + } + } + } + + // Process any remaining buffer + if (buffer.trim().startsWith('data:')) { + const jsonStr = buffer.trim().slice(5).trim() + if (jsonStr && jsonStr !== '[DONE]') { + try { + const parsed = JSON.parse(jsonStr) + const text = parsed?.candidates?.[0]?.content?.parts?.[0]?.text + if (text) { + yield text + } + } catch { + // Skip malformed final chunk + } + } + } + } finally { + reader.releaseLock() + } +} + +export function parseItemIds(text: string): string[] { + const match = text.match(/\[ITEMS:\s*([^\]]+)\]/) + if (!match) return [] + return match[1] + .split(',') + .map((id) => id.trim()) + .filter(Boolean) +} + +export function stripItemsSuffix(text: string): string { + return text.replace(/\n?\[ITEMS:[^\]]*\]\s*$/, '').trim() +}