project-nomad/admin/inertia/components/chat/ChatInterface.tsx
Henry Estela c8ce28a84f
fix(ai-chat): ingestion of documents with openai and add cleanup button
Added a cleanup failed button for Processing Queue in the Knowledge Base
since documents that fail to process tend to get stuck and then can't be
cleared.

Fixed the ingestion of documents for OpenAI servers.

Updated some text in the chat and chat settings since user will need to
manually download models when using a non-ollama remote gpu server.
2026-03-25 17:18:05 -07:00

223 lines
8.8 KiB
TypeScript

import { IconSend, IconWand } from '@tabler/icons-react'
import { useState, useRef, useEffect } from 'react'
import classNames from '~/lib/classNames'
import { ChatMessage } from '../../../types/chat'
import ChatMessageBubble from './ChatMessageBubble'
import ChatAssistantAvatar from './ChatAssistantAvatar'
import BouncingDots from '../BouncingDots'
import StyledModal from '../StyledModal'
import api from '~/lib/api'
import { DEFAULT_QUERY_REWRITE_MODEL } from '../../../constants/ollama'
import { useNotifications } from '~/context/NotificationContext'
import { usePage } from '@inertiajs/react'
interface ChatInterfaceProps {
messages: ChatMessage[]
onSendMessage: (message: string) => void
isLoading?: boolean
chatSuggestions?: string[]
chatSuggestionsEnabled?: boolean
chatSuggestionsLoading?: boolean
rewriteModelAvailable?: boolean
}
export default function ChatInterface({
messages,
onSendMessage,
isLoading = false,
chatSuggestions = [],
chatSuggestionsEnabled = false,
chatSuggestionsLoading = false,
rewriteModelAvailable = false
}: ChatInterfaceProps) {
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
const { addNotification } = useNotifications()
const [input, setInput] = useState('')
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false)
const [isDownloading, setIsDownloading] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const handleDownloadModel = async () => {
setIsDownloading(true)
try {
await api.downloadModel(DEFAULT_QUERY_REWRITE_MODEL)
addNotification({ type: 'success', message: 'Model download queued' })
} catch (error) {
addNotification({ type: 'error', message: 'Failed to queue model download' })
} finally {
setIsDownloading(false)
setDownloadDialogOpen(false)
}
}
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
useEffect(() => {
scrollToBottom()
}, [messages])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (input.trim() && !isLoading) {
onSendMessage(input.trim())
setInput('')
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit(e)
}
}
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value)
// Auto-resize textarea
e.target.style.height = 'auto'
e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px`
}
return (
<div className="flex-1 flex flex-col min-h-0 bg-surface-primary shadow-sm">
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
{messages.length === 0 ? (
<div className="h-full flex items-center justify-center">
<div className="text-center max-w-md">
<IconWand className="h-16 w-16 text-desert-green mx-auto mb-4 opacity-50" />
<h3 className="text-lg font-medium text-text-primary mb-2">Start a conversation</h3>
<p className="text-text-muted text-sm">
Interact with your installed language models directly in the Command Center.
</p>
{chatSuggestionsEnabled && chatSuggestions && chatSuggestions.length > 0 && !chatSuggestionsLoading && (
<div className="mt-8">
<h4 className="text-sm font-medium text-text-secondary mb-2">Suggestions:</h4>
<div className="flex flex-col gap-2">
{chatSuggestions.map((suggestion, index) => (
<button
key={index}
onClick={() => {
setInput(suggestion)
// Focus the textarea after setting input
setTimeout(() => {
textareaRef.current?.focus()
}, 0)
}}
className="px-4 py-2 bg-surface-secondary hover:bg-surface-secondary rounded-lg text-sm text-text-primary transition-colors"
>
{suggestion}
</button>
))}
</div>
</div>
)}
{/* Display bouncing dots while loading suggestions */}
{chatSuggestionsEnabled && chatSuggestionsLoading && <BouncingDots text="Thinking" containerClassName="mt-8" />}
{!chatSuggestionsEnabled && (
<div className="mt-8 text-sm text-text-muted">
Need some inspiration? Enable chat suggestions in settings to get started with example prompts.
</div>
)}
</div>
</div>
) : (
<>
{messages.map((message) => (
<div
key={message.id}
className={classNames(
'flex gap-4',
message.role === 'user' ? 'justify-end' : 'justify-start'
)}
>
{message.role === 'assistant' && <ChatAssistantAvatar />}
<ChatMessageBubble message={message} />
</div>
))}
{/* Loading/thinking indicator */}
{isLoading && (
<div className="flex gap-4 justify-start">
<ChatAssistantAvatar />
<div className="max-w-[70%] rounded-lg px-4 py-3 bg-surface-secondary text-text-primary">
<BouncingDots text="Thinking" />
</div>
</div>
)}
<div ref={messagesEndRef} />
</>
)}
</div>
<div className="border-t border-border-subtle bg-surface-primary px-6 py-4 flex-shrink-0 min-h-[90px]">
<form onSubmit={handleSubmit} className="flex gap-3 items-end">
<div className="flex-1 relative">
<textarea
ref={textareaRef}
value={input}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder={`Type your message to ${aiAssistantName}... (Shift+Enter for new line)`}
className="w-full resize-none rounded-lg border border-border-default px-4 py-3 pr-12 focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent disabled:bg-surface-secondary disabled:text-text-muted"
rows={1}
disabled={isLoading}
style={{ maxHeight: '200px' }}
/>
</div>
<button
type="submit"
disabled={!input.trim() || isLoading}
className={classNames(
'p-3 rounded-lg transition-all duration-200 flex-shrink-0 mb-2',
!input.trim() || isLoading
? 'bg-border-default text-text-muted cursor-not-allowed'
: 'bg-desert-green text-white hover:bg-desert-green/90 hover:scale-105'
)}
>
{isLoading ? (
<div className="h-6 w-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<IconSend className="h-6 w-6" />
)}
</button>
</form>
{!rewriteModelAvailable && (
<div className="text-sm text-text-muted mt-2">
The {DEFAULT_QUERY_REWRITE_MODEL} model is not installed. Consider{' '}
<button
onClick={() => setDownloadDialogOpen(true)}
className="text-desert-green underline hover:text-desert-green/80 cursor-pointer"
>
downloading it
</button>{' '}
for improved retrieval-augmented generation (RAG) performance.
</div>
)}
<StyledModal
open={downloadDialogOpen}
title={`Download ${DEFAULT_QUERY_REWRITE_MODEL}?`}
confirmText="Download"
cancelText="Cancel"
confirmIcon='IconDownload'
confirmVariant='primary'
confirmLoading={isDownloading}
onConfirm={handleDownloadModel}
onCancel={() => setDownloadDialogOpen(false)}
onClose={() => setDownloadDialogOpen(false)}
>
<p className="text-text-primary">
This will dispatch a background download job for{' '}
<span className="font-mono font-medium">{DEFAULT_QUERY_REWRITE_MODEL}</span> and may take some time to complete. The model
will be used to rewrite queries for improved RAG retrieval performance. Note that download is only supported when using Ollama. If using an OpenAI API interface, please download the model with that software.
</p>
</StyledModal>
</div>
</div>
)
}