feat: US-009 - Chat widget — Gemini Flash integration

This commit is contained in:
2026-02-15 18:24:42 +00:00
parent 273c143d5e
commit 29e1728e11
4 changed files with 379 additions and 77 deletions
+1 -1
View File
@@ -175,7 +175,7 @@
"Typecheck passes" "Typecheck passes"
], ],
"priority": 9, "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." "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."
}, },
{ {
+35
View File
@@ -19,6 +19,12 @@
- `prefersReducedMotion` pattern: read `window.matchMedia` at module level, use in framer-motion variants to skip animation - `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 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 - 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 `<!--ITEMS:id1,id2-->` 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 - 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 - `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 `<!--ITEMS:id1,id2-->` 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 `<!--ITEMS:-->` 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
---
+137 -22
View File
@@ -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 { 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 const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
interface ChatMessage { const MAX_HISTORY = 10
role: 'user' | 'assistant'
content: string
}
const buttonVariants = { const buttonVariants = {
hidden: prefersReducedMotion hidden: prefersReducedMotion
@@ -38,15 +42,16 @@ const panelVariants = {
: { opacity: 0, scale: 0.95, transition: { duration: 0.15, ease: 'easeIn' } }, : { 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() { export function ChatWidget() {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [messages, setMessages] = useState<ChatMessage[]>([]) const [messages, setMessages] = useState<ChatMessage[]>([])
const [inputValue, setInputValue] = useState('') const [inputValue, setInputValue] = useState('')
const [isStreaming, setIsStreaming] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null) const inputRef = useRef<HTMLTextAreaElement>(null)
const geminiAvailable = isGeminiAvailable()
// Auto-scroll to latest message // Auto-scroll to latest message
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
@@ -59,18 +64,68 @@ export function ChatWidget() {
} }
}, [isOpen]) }, [isOpen])
const handleSubmit = () => { const handleSubmit = useCallback(async () => {
const trimmed = inputValue.trim() const trimmed = inputValue.trim()
if (!trimmed) return if (!trimmed || isStreaming) return
setMessages((prev) => [ const userMessage: ChatMessage = { role: 'user', content: trimmed }
...prev, const updatedMessages = [...messages, userMessage]
{ role: 'user', content: trimmed },
{ role: 'assistant', content: PLACEHOLDER_RESPONSE }, // Cap history to last MAX_HISTORY messages, strip internal metadata
]) const historyForApi = updatedMessages.slice(-MAX_HISTORY).map((msg) => ({
...msg,
content: msg.content.replace(/\n?<!--ITEMS:[^>]*-->/, '').trim(),
}))
setMessages(updatedMessages)
setInputValue('') 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<!--ITEMS:${itemIds.join(',')}-->`
: 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<HTMLTextAreaElement>) => { const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault() e.preventDefault()
@@ -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?<!--ITEMS:[^>]*-->/, '').trim()
}
return ( return (
<> <>
{/* Chat panel */} {/* Chat panel */}
@@ -105,7 +165,6 @@ export function ChatWidget() {
transformOrigin: 'bottom right', transformOrigin: 'bottom right',
}} }}
> >
{/* Use inline style for sm-width via CSS class override */}
<style>{` <style>{`
@media (min-width: 640px) { @media (min-width: 640px) {
[data-chat-panel] { width: 380px; max-height: 480px; } [data-chat-panel] { width: 380px; max-height: 480px; }
@@ -183,7 +242,21 @@ export function ChatWidget() {
}} }}
className="pmr-scrollbar" className="pmr-scrollbar"
> >
{messages.length === 0 && ( {!geminiAvailable && (
<div
style={{
textAlign: 'center',
padding: '32px 16px',
color: 'var(--text-tertiary)',
fontSize: '13px',
lineHeight: 1.5,
}}
>
Chat is currently unavailable.
</div>
)}
{geminiAvailable && messages.length === 0 && (
<div <div
style={{ style={{
textAlign: 'center', textAlign: 'center',
@@ -221,16 +294,47 @@ export function ChatWidget() {
border: msg.role === 'user' border: msg.role === 'user'
? '1px solid var(--accent-border)' ? '1px solid var(--accent-border)'
: '1px solid var(--border-light)', : '1px solid var(--border-light)',
whiteSpace: 'pre-wrap',
}} }}
> >
{msg.content} {getDisplayText(msg.content)}
</div> </div>
</div> </div>
))} ))}
{/* Typing indicator */}
{isStreaming && messages.length > 0 && messages[messages.length - 1].content === '' && (
<div style={{ display: 'flex', justifyContent: 'flex-start' }}>
<div
style={{
padding: '10px 14px',
borderRadius: '12px 12px 12px 4px',
background: 'var(--bg-dashboard)',
border: '1px solid var(--border-light)',
display: 'flex',
alignItems: 'center',
gap: '6px',
color: 'var(--text-tertiary)',
fontSize: '13px',
}}
>
<Loader2
size={14}
strokeWidth={2}
style={{
animation: 'spin 1s linear infinite',
}}
/>
<span>Thinking...</span>
</div>
</div>
)}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
{/* Input area */} {/* Input area */}
{geminiAvailable && (
<div <div
style={{ style={{
padding: '12px 16px', padding: '12px 16px',
@@ -248,6 +352,7 @@ export function ChatWidget() {
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Ask me anything..." placeholder="Ask me anything..."
rows={1} rows={1}
disabled={isStreaming}
style={{ style={{
flex: 1, flex: 1,
resize: 'none', resize: 'none',
@@ -263,6 +368,7 @@ export function ChatWidget() {
maxHeight: '80px', maxHeight: '80px',
overflowY: 'auto', overflowY: 'auto',
transition: 'border-color 150ms ease-out', transition: 'border-color 150ms ease-out',
opacity: isStreaming ? 0.6 : 1,
}} }}
onFocus={(e) => { onFocus={(e) => {
e.currentTarget.style.borderColor = 'var(--accent)' e.currentTarget.style.borderColor = 'var(--accent)'
@@ -273,7 +379,7 @@ export function ChatWidget() {
/> />
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!inputValue.trim()} disabled={!inputValue.trim() || isStreaming}
aria-label="Send message" aria-label="Send message"
style={{ style={{
display: 'flex', display: 'flex',
@@ -283,9 +389,9 @@ export function ChatWidget() {
height: '36px', height: '36px',
borderRadius: '8px', borderRadius: '8px',
border: 'none', border: 'none',
background: inputValue.trim() ? 'var(--accent)' : 'var(--border-light)', background: inputValue.trim() && !isStreaming ? 'var(--accent)' : 'var(--border-light)',
color: inputValue.trim() ? '#FFFFFF' : 'var(--text-tertiary)', color: inputValue.trim() && !isStreaming ? '#FFFFFF' : 'var(--text-tertiary)',
cursor: inputValue.trim() ? 'pointer' : 'default', cursor: inputValue.trim() && !isStreaming ? 'pointer' : 'default',
flexShrink: 0, flexShrink: 0,
transition: 'background-color 150ms ease-out, color 150ms ease-out', transition: 'background-color 150ms ease-out, color 150ms ease-out',
}} }}
@@ -293,6 +399,7 @@ export function ChatWidget() {
<Send size={16} strokeWidth={2} /> <Send size={16} strokeWidth={2} />
</button> </button>
</div> </div>
)}
</div> </div>
</motion.div> </motion.div>
)} )}
@@ -328,6 +435,14 @@ export function ChatWidget() {
> >
{isOpen ? <X size={20} strokeWidth={2} /> : <MessageCircle size={20} strokeWidth={2} />} {isOpen ? <X size={20} strokeWidth={2} /> : <MessageCircle size={20} strokeWidth={2} />}
</motion.button> </motion.button>
{/* Spinner keyframes */}
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</> </>
) )
} }
+152
View File
@@ -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<string> {
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()
}