feat: US-009 - Chat widget — Gemini Flash integration
This commit is contained in:
+1
-1
@@ -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."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
---
|
||||||
|
|||||||
+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 { 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,17 +64,67 @@ 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) {
|
||||||
@@ -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,78 +294,112 @@ 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 */}
|
||||||
<div
|
{geminiAvailable && (
|
||||||
style={{
|
<div
|
||||||
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"
|
|
||||||
style={{
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderTop: '1px solid var(--border-light)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'flex-end',
|
||||||
justifyContent: 'center',
|
gap: '8px',
|
||||||
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',
|
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
transition: 'background-color 150ms ease-out, color 150ms ease-out',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Send size={16} strokeWidth={2} />
|
<textarea
|
||||||
</button>
|
ref={inputRef}
|
||||||
</div>
|
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>
|
</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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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