feat: US-014 - Migrate production chat from Gemini to OpenRouter

This commit is contained in:
2026-02-16 00:24:53 +00:00
parent 7f3428184f
commit 4bab9b369c
4 changed files with 75 additions and 49 deletions
+8 -8
View File
@@ -3,12 +3,12 @@ import { motion, AnimatePresence } from 'framer-motion'
import { MessageCircle, X, Send, Loader2 } from 'lucide-react'
import {
sendChatMessage,
isGeminiAvailable,
isLLMAvailable,
parseItemIds,
stripItemsSuffix,
GEMINI_DISPLAY_NAME,
LLM_DISPLAY_NAME,
type ChatMessage,
} from '@/lib/gemini'
} from '@/lib/llm'
import { buildPaletteData } from '@/lib/search'
import type { PaletteItem, PaletteAction } from '@/lib/search'
import { iconByType, iconColorStyles } from '@/lib/palette-icons'
@@ -64,7 +64,7 @@ export function ChatWidget({ onAction }: ChatWidgetProps) {
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
const geminiAvailable = isGeminiAvailable()
const llmAvailable = isLLMAvailable()
// Build palette map for looking up items by ID
const paletteMap = useMemo(() => {
@@ -264,7 +264,7 @@ export function ChatWidget({ onAction }: ChatWidgetProps) {
color: 'var(--text-tertiary)',
}}
>
{GEMINI_DISPLAY_NAME}
{LLM_DISPLAY_NAME}
</span>
</div>
<button
@@ -306,7 +306,7 @@ export function ChatWidget({ onAction }: ChatWidgetProps) {
}}
className="pmr-scrollbar"
>
{!geminiAvailable && (
{!llmAvailable && (
<div
style={{
textAlign: 'center',
@@ -320,7 +320,7 @@ export function ChatWidget({ onAction }: ChatWidgetProps) {
</div>
)}
{geminiAvailable && messages.length === 0 && (
{llmAvailable && messages.length === 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{/* Welcome bubble — styled as assistant message */}
<div style={{ display: 'flex', justifyContent: 'flex-start' }}>
@@ -537,7 +537,7 @@ export function ChatWidget({ onAction }: ChatWidgetProps) {
</div>
{/* Input area */}
{geminiAvailable && (
{llmAvailable && (
<div
style={{
padding: '12px 16px',
+30 -31
View File
@@ -5,20 +5,20 @@ export interface ChatMessage {
content: string
}
export const GEMINI_MODEL = 'gemini-3-flash-preview'
export const GEMINI_DISPLAY_NAME = 'Gemini 3 Flash'
export const LLM_MODEL = 'z-ai/glm-5'
export const LLM_DISPLAY_NAME = 'GLM-5'
const GEMINI_API_BASE = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}`
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'
function getApiKey(): string | undefined {
return import.meta.env.VITE_GEMINI_API_KEY as string | undefined
return import.meta.env.VITE_OPEN_ROUTER_API_KEY as string | undefined
}
export function isGeminiAvailable(): boolean {
export function isLLMAvailable(): boolean {
return !!getApiKey()
}
function buildSystemPrompt(): string {
export function buildSystemPrompt(): string {
const texts = buildEmbeddingTexts()
const cvContent = texts.map((t) => `[${t.id}] ${t.text}`).join('\n')
@@ -45,20 +45,18 @@ 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,
},
model: LLM_MODEL,
stream: true,
temperature: 0.7,
max_tokens: 512,
messages: [
{ role: 'system', content: systemPrompt },
...messages.map((msg) => ({
role: msg.role,
content: msg.content,
})),
],
}
}
@@ -67,23 +65,25 @@ export async function* sendChatMessage(
): AsyncGenerator<string> {
const apiKey = getApiKey()
if (!apiKey) {
throw new Error('Gemini API key not configured')
throw new Error('LLM 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),
const response = await fetch(OPENROUTER_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'HTTP-Referer': window.location.origin,
'X-Title': 'Andy Charlwood Portfolio',
},
)
body: JSON.stringify(body),
})
if (!response.ok) {
throw new Error(`Gemini API error: ${response.status}`)
throw new Error(`LLM API error: ${response.status}`)
}
const reader = response.body?.getReader()
@@ -102,7 +102,6 @@ export async function* sendChatMessage(
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) {
@@ -114,7 +113,7 @@ export async function* sendChatMessage(
try {
const parsed = JSON.parse(jsonStr)
const text = parsed?.candidates?.[0]?.content?.parts?.[0]?.text
const text = parsed?.choices?.[0]?.delta?.content
if (text) {
yield text
}
@@ -130,7 +129,7 @@ export async function* sendChatMessage(
if (jsonStr && jsonStr !== '[DONE]') {
try {
const parsed = JSON.parse(jsonStr)
const text = parsed?.candidates?.[0]?.content?.parts?.[0]?.text
const text = parsed?.choices?.[0]?.delta?.content
if (text) {
yield text
}