feat: US-009 - Chat widget — Gemini Flash integration
This commit is contained in:
+1
-1
@@ -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."
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 `<!--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
|
||||
- `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
@@ -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<ChatMessage[]>([])
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const geminiAvailable = isGeminiAvailable()
|
||||
|
||||
// Auto-scroll to latest message
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
@@ -59,18 +64,68 @@ 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?<!--ITEMS:[^>]*-->/, '').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<!--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>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
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 (
|
||||
<>
|
||||
{/* Chat panel */}
|
||||
@@ -105,7 +165,6 @@ export function ChatWidget() {
|
||||
transformOrigin: 'bottom right',
|
||||
}}
|
||||
>
|
||||
{/* Use inline style for sm-width via CSS class override */}
|
||||
<style>{`
|
||||
@media (min-width: 640px) {
|
||||
[data-chat-panel] { width: 380px; max-height: 480px; }
|
||||
@@ -183,7 +242,21 @@ export function ChatWidget() {
|
||||
}}
|
||||
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
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
@@ -221,16 +294,47 @@ export function ChatWidget() {
|
||||
border: msg.role === 'user'
|
||||
? '1px solid var(--accent-border)'
|
||||
: '1px solid var(--border-light)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
{getDisplayText(msg.content)}
|
||||
</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>
|
||||
|
||||
{/* Input area */}
|
||||
{geminiAvailable && (
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
@@ -248,6 +352,7 @@ export function ChatWidget() {
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask me anything..."
|
||||
rows={1}
|
||||
disabled={isStreaming}
|
||||
style={{
|
||||
flex: 1,
|
||||
resize: 'none',
|
||||
@@ -263,6 +368,7 @@ export function ChatWidget() {
|
||||
maxHeight: '80px',
|
||||
overflowY: 'auto',
|
||||
transition: 'border-color 150ms ease-out',
|
||||
opacity: isStreaming ? 0.6 : 1,
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--accent)'
|
||||
@@ -273,7 +379,7 @@ export function ChatWidget() {
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!inputValue.trim()}
|
||||
disabled={!inputValue.trim() || isStreaming}
|
||||
aria-label="Send message"
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -283,9 +389,9 @@ export function ChatWidget() {
|
||||
height: '36px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: inputValue.trim() ? 'var(--accent)' : 'var(--border-light)',
|
||||
color: inputValue.trim() ? '#FFFFFF' : 'var(--text-tertiary)',
|
||||
cursor: inputValue.trim() ? 'pointer' : 'default',
|
||||
background: inputValue.trim() && !isStreaming ? 'var(--accent)' : 'var(--border-light)',
|
||||
color: inputValue.trim() && !isStreaming ? '#FFFFFF' : 'var(--text-tertiary)',
|
||||
cursor: inputValue.trim() && !isStreaming ? 'pointer' : 'default',
|
||||
flexShrink: 0,
|
||||
transition: 'background-color 150ms ease-out, color 150ms ease-out',
|
||||
}}
|
||||
@@ -293,6 +399,7 @@ export function ChatWidget() {
|
||||
<Send size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -328,6 +435,14 @@ export function ChatWidget() {
|
||||
>
|
||||
{isOpen ? <X size={20} strokeWidth={2} /> : <MessageCircle size={20} strokeWidth={2} />}
|
||||
</motion.button>
|
||||
|
||||
{/* Spinner keyframes */}
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user