feat: US-009 - Chat widget — Gemini Flash integration
This commit is contained in:
+191
-76
@@ -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,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?<!--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) {
|
||||
@@ -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,78 +294,112 @@ 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 */}
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderTop: '1px solid var(--border-light)',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
gap: '8px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask me anything..."
|
||||
rows={1}
|
||||
style={{
|
||||
flex: 1,
|
||||
resize: 'none',
|
||||
border: '1px solid var(--border-light)',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 12px',
|
||||
fontSize: '13px',
|
||||
lineHeight: 1.5,
|
||||
color: 'var(--text-primary)',
|
||||
background: 'var(--surface)',
|
||||
outline: 'none',
|
||||
fontFamily: 'inherit',
|
||||
maxHeight: '80px',
|
||||
overflowY: 'auto',
|
||||
transition: 'border-color 150ms ease-out',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--accent)'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-light)'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!inputValue.trim()}
|
||||
aria-label="Send message"
|
||||
{geminiAvailable && (
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderTop: '1px solid var(--border-light)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '36px',
|
||||
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',
|
||||
alignItems: 'flex-end',
|
||||
gap: '8px',
|
||||
flexShrink: 0,
|
||||
transition: 'background-color 150ms ease-out, color 150ms ease-out',
|
||||
}}
|
||||
>
|
||||
<Send size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask me anything..."
|
||||
rows={1}
|
||||
disabled={isStreaming}
|
||||
style={{
|
||||
flex: 1,
|
||||
resize: 'none',
|
||||
border: '1px solid var(--border-light)',
|
||||
borderRadius: '8px',
|
||||
padding: '10px 12px',
|
||||
fontSize: '13px',
|
||||
lineHeight: 1.5,
|
||||
color: 'var(--text-primary)',
|
||||
background: 'var(--surface)',
|
||||
outline: 'none',
|
||||
fontFamily: 'inherit',
|
||||
maxHeight: '80px',
|
||||
overflowY: 'auto',
|
||||
transition: 'border-color 150ms ease-out',
|
||||
opacity: isStreaming ? 0.6 : 1,
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--accent)'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-light)'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!inputValue.trim() || isStreaming}
|
||||
aria-label="Send message"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<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