mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-28 03:29:25 +01:00
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.
223 lines
8.8 KiB
TypeScript
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>
|
|
)
|
|
}
|