project-nomad/admin/inertia/components/chat/index.tsx
PR Bot ae856b268e feat: add MiniMax as optional cloud LLM provider
- Add MiniMaxService with OpenAI-compatible API integration
- Route MiniMax models (MiniMax-M2.7, MiniMax-M2.7-highspeed) through
  existing chat controller alongside Ollama
- Cloud models appear in model selector when MINIMAX_API_KEY is set
- Add MINIMAX_API_KEY env var support
- Add 8 unit tests + 3 integration tests
- Update README with MiniMax mention
2026-03-26 12:29:14 +08: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) : 'Cloud'})
</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>
)
}