project-nomad/admin/inertia/components/chat/index.tsx
Henry Estela f98664921a
feat(ai-chat): Add support for OpenAI API
Exisiting Ollama API support still functions as before. OpenAI vs
Ollama API mostly have the same features, however model file size is not
support with OpenAI's API so when a user chooses one of those then the
models will just show up as the model name without the size.

`npm install openai` triggered some updates in admin/package-lock.json
such as adding many instances of "dev: true".

This further enhances the user's ability to run the LLM on a different
host.
2026-03-25 17:18:05 -07:00

416 lines
14 KiB
TypeScript

import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import ChatSidebar from './ChatSidebar'
import ChatInterface from './ChatInterface'
import StyledModal from '../StyledModal'
import api from '~/lib/api'
import { formatBytes } from '~/lib/util'
import { useModals } from '~/context/ModalContext'
import { ChatMessage } from '../../../types/chat'
import classNames from '~/lib/classNames'
import { IconX } from '@tabler/icons-react'
import { DEFAULT_QUERY_REWRITE_MODEL } from '../../../constants/ollama'
import { useSystemSetting } from '~/hooks/useSystemSetting'
interface ChatProps {
enabled: boolean
isInModal?: boolean
onClose?: () => void
suggestionsEnabled?: boolean
streamingEnabled?: boolean
}
export default function Chat({
enabled,
isInModal,
onClose,
suggestionsEnabled = false,
streamingEnabled = true,
}: ChatProps) {
const queryClient = useQueryClient()
const { openModal, closeAllModals } = useModals()
const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [selectedModel, setSelectedModel] = useState<string>('')
const [isStreamingResponse, setIsStreamingResponse] = useState(false)
const streamAbortRef = useRef<AbortController | null>(null)
// Fetch all sessions
const { data: sessions = [] } = useQuery({
queryKey: ['chatSessions'],
queryFn: () => api.getChatSessions(),
enabled,
select: (data) =>
data?.map((s) => ({
id: s.id,
title: s.title,
model: s.model || undefined,
timestamp: new Date(s.timestamp),
lastMessage: s.lastMessage || undefined,
})) || [],
})
const activeSession = sessions.find((s) => s.id === activeSessionId)
const { data: lastModelSetting } = useSystemSetting({ key: 'chat.lastModel', enabled })
const { data: installedModels = [], isLoading: isLoadingModels } = useQuery({
queryKey: ['installedModels'],
queryFn: () => api.getInstalledModels(),
enabled,
select: (data) => data || [],
})
const { data: chatSuggestions, isLoading: chatSuggestionsLoading } = useQuery<string[]>({
queryKey: ['chatSuggestions'],
queryFn: async ({ signal }) => {
const res = await api.getChatSuggestions(signal)
return res ?? []
},
enabled: suggestionsEnabled && !activeSessionId,
refetchOnWindowFocus: false,
refetchOnMount: false,
})
const rewriteModelAvailable = useMemo(() => {
return installedModels.some(model => model.name === DEFAULT_QUERY_REWRITE_MODEL)
}, [installedModels])
const deleteAllSessionsMutation = useMutation({
mutationFn: () => api.deleteAllChatSessions(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['chatSessions'] })
setActiveSessionId(null)
setMessages([])
closeAllModals()
},
})
const chatMutation = useMutation({
mutationFn: (request: {
model: string
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
sessionId?: number
}) => api.sendChatMessage({ ...request, stream: false }),
onSuccess: async (data) => {
if (!data || !activeSessionId) {
throw new Error('No response from Ollama')
}
// Add assistant message
const assistantMessage: ChatMessage = {
id: `msg-${Date.now()}-assistant`,
role: 'assistant',
content: data.message?.content || 'Sorry, I could not generate a response.',
timestamp: new Date(),
}
setMessages((prev) => [...prev, assistantMessage])
// Refresh sessions to pick up backend-persisted messages and title
queryClient.invalidateQueries({ queryKey: ['chatSessions'] })
setTimeout(() => queryClient.invalidateQueries({ queryKey: ['chatSessions'] }), 3000)
},
onError: (error) => {
console.error('Error sending message:', error)
const errorMessage: ChatMessage = {
id: `msg-${Date.now()}-error`,
role: 'assistant',
content: 'Sorry, there was an error processing your request. Please try again.',
timestamp: new Date(),
}
setMessages((prev) => [...prev, errorMessage])
},
})
// Set default model: prefer last used model, fall back to first installed if last model not available
useEffect(() => {
if (installedModels.length > 0 && !selectedModel) {
const lastModel = lastModelSetting?.value as string | undefined
if (lastModel && installedModels.some((m) => m.name === lastModel)) {
setSelectedModel(lastModel)
} else {
setSelectedModel(installedModels[0].name)
}
}
}, [installedModels, selectedModel, lastModelSetting])
// Persist model selection
useEffect(() => {
if (selectedModel) {
api.updateSetting('chat.lastModel', selectedModel)
}
}, [selectedModel])
const handleNewChat = useCallback(() => {
// Just clear the active session and messages - don't create a session yet
setActiveSessionId(null)
setMessages([])
}, [])
const handleClearHistory = useCallback(() => {
openModal(
<StyledModal
title="Clear All Chat History?"
onConfirm={() => deleteAllSessionsMutation.mutate()}
onCancel={closeAllModals}
open={true}
confirmText="Clear All"
cancelText="Cancel"
confirmVariant="danger"
>
<p className="text-text-primary">
Are you sure you want to delete all chat sessions? This action cannot be undone and all
conversations will be permanently deleted.
</p>
</StyledModal>,
'confirm-clear-history-modal'
)
}, [openModal, closeAllModals, deleteAllSessionsMutation])
const handleSessionSelect = useCallback(
async (sessionId: string) => {
// Cancel any ongoing suggestions fetch
queryClient.cancelQueries({ queryKey: ['chatSuggestions'] })
setActiveSessionId(sessionId)
// Load messages for this session
const sessionData = await api.getChatSession(sessionId)
if (sessionData?.messages) {
setMessages(
sessionData.messages.map((m) => ({
id: m.id,
role: m.role,
content: m.content,
timestamp: new Date(m.timestamp),
}))
)
} else {
setMessages([])
}
// Set the model to match the session's model if it exists and is available
if (sessionData?.model) {
setSelectedModel(sessionData.model)
}
},
[installedModels, queryClient]
)
const handleSendMessage = useCallback(
async (content: string) => {
let sessionId = activeSessionId
// Create a new session if none exists
if (!sessionId) {
const newSession = await api.createChatSession('New Chat', selectedModel)
if (newSession) {
sessionId = newSession.id
setActiveSessionId(sessionId)
queryClient.invalidateQueries({ queryKey: ['chatSessions'] })
} else {
return
}
}
// Add user message to UI
const userMessage: ChatMessage = {
id: `msg-${Date.now()}`,
role: 'user',
content,
timestamp: new Date(),
}
setMessages((prev) => [...prev, userMessage])
const chatMessages = [
...messages.map((m) => ({ role: m.role, content: m.content })),
{ role: 'user' as const, content },
]
if (streamingEnabled !== false) {
// Streaming path
const abortController = new AbortController()
streamAbortRef.current = abortController
setIsStreamingResponse(true)
const assistantMsgId = `msg-${Date.now()}-assistant`
let isFirstChunk = true
let fullContent = ''
let thinkingContent = ''
let isThinkingPhase = true
let thinkingStartTime: number | null = null
let thinkingDuration: number | null = null
try {
await api.streamChatMessage(
{ model: selectedModel || 'llama3.2', messages: chatMessages, stream: true, sessionId: sessionId ? Number(sessionId) : undefined },
(chunkContent, chunkThinking, done) => {
if (chunkThinking.length > 0 && thinkingStartTime === null) {
thinkingStartTime = Date.now()
}
if (isFirstChunk) {
isFirstChunk = false
setIsStreamingResponse(false)
setMessages((prev) => [
...prev,
{
id: assistantMsgId,
role: 'assistant',
content: chunkContent,
thinking: chunkThinking,
timestamp: new Date(),
isStreaming: true,
isThinking: chunkThinking.length > 0 && chunkContent.length === 0,
thinkingDuration: undefined,
},
])
} else {
if (isThinkingPhase && chunkContent.length > 0) {
isThinkingPhase = false
if (thinkingStartTime !== null) {
thinkingDuration = Math.max(1, Math.round((Date.now() - thinkingStartTime) / 1000))
}
}
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
? {
...m,
content: m.content + chunkContent,
thinking: (m.thinking ?? '') + chunkThinking,
isStreaming: !done,
isThinking: isThinkingPhase,
thinkingDuration: thinkingDuration ?? undefined,
}
: m
)
)
}
fullContent += chunkContent
thinkingContent += chunkThinking
},
abortController.signal
)
} catch (error: any) {
if (error?.name !== 'AbortError') {
setMessages((prev) => {
const hasAssistantMsg = prev.some((m) => m.id === assistantMsgId)
if (hasAssistantMsg) {
return prev.map((m) =>
m.id === assistantMsgId ? { ...m, isStreaming: false } : m
)
}
return [
...prev,
{
id: assistantMsgId,
role: 'assistant',
content: 'Sorry, there was an error processing your request. Please try again.',
timestamp: new Date(),
},
]
})
}
} finally {
setIsStreamingResponse(false)
streamAbortRef.current = null
}
if (fullContent && sessionId) {
// Ensure the streaming cursor is removed
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId ? { ...m, isStreaming: false } : m
)
)
// Refresh sessions to pick up backend-persisted messages and title
queryClient.invalidateQueries({ queryKey: ['chatSessions'] })
setTimeout(() => queryClient.invalidateQueries({ queryKey: ['chatSessions'] }), 3000)
}
} else {
// Non-streaming (legacy) path
chatMutation.mutate({
model: selectedModel || 'llama3.2',
messages: chatMessages,
sessionId: sessionId ? Number(sessionId) : undefined,
})
}
},
[activeSessionId, messages, selectedModel, chatMutation, queryClient, streamingEnabled]
)
return (
<div
className={classNames(
'flex border border-border-subtle overflow-hidden shadow-sm w-full',
isInModal ? 'h-full rounded-lg' : 'h-screen'
)}
>
<ChatSidebar
sessions={sessions}
activeSessionId={activeSessionId}
onSessionSelect={handleSessionSelect}
onNewChat={handleNewChat}
onClearHistory={handleClearHistory}
isInModal={isInModal}
/>
<div className="flex-1 flex flex-col min-h-0">
<div className="px-6 py-3 border-b border-border-subtle bg-surface-secondary flex items-center justify-between h-[75px] flex-shrink-0">
<h2 className="text-lg font-semibold text-text-primary">
{activeSession?.title || 'New Chat'}
</h2>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label htmlFor="model-select" className="text-sm text-text-secondary">
Model:
</label>
{isLoadingModels ? (
<div className="text-sm text-text-muted">Loading models...</div>
) : installedModels.length === 0 ? (
<div className="text-sm text-red-600">No models installed</div>
) : (
<select
id="model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="px-3 py-1.5 border border-border-default rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent bg-surface-primary"
>
{installedModels.map((model) => (
<option key={model.name} value={model.name}>
{model.name}{model.size > 0 ? ` (${formatBytes(model.size)})` : ''}
</option>
))}
</select>
)}
</div>
{isInModal && (
<button
onClick={() => {
if (onClose) {
onClose()
}
}}
className="rounded-lg hover:bg-surface-secondary transition-colors"
>
<IconX className="h-6 w-6 text-text-muted" />
</button>
)}
</div>
</div>
<ChatInterface
messages={messages}
onSendMessage={handleSendMessage}
isLoading={isStreamingResponse || chatMutation.isPending}
chatSuggestions={chatSuggestions}
chatSuggestionsEnabled={suggestionsEnabled}
chatSuggestionsLoading={chatSuggestionsLoading}
rewriteModelAvailable={rewriteModelAvailable}
/>
</div>
</div>
)
}