| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177 |
- import { useBreakpoint } from 'common'
- import { ArrowUp, Loader2, Square } from 'lucide-react'
- import { ChangeEvent, FormEvent, forwardRef, KeyboardEvent, memo, useRef } from 'react'
- import { ExpandingTextArea } from 'ui'
- import { cn } from 'ui/src/lib/utils'
- import { ButtonTooltip } from '../ButtonTooltip'
- import { type SqlSnippet } from './AIAssistant.types'
- import { ModelSelector } from './ModelSelector'
- import { getSnippetContent, SnippetRow } from './SnippetRow'
- import type { AssistantModelId } from '@/lib/ai/model.utils'
- export interface FormProps {
- /* The ref for the textarea, optional. Exposed for the CommandsPopover to attach events. */
- textAreaRef?: React.RefObject<HTMLTextAreaElement | null>
- /* The loading state of the form */
- loading: boolean
- /* The disabled state of the form */
- disabled?: boolean
- /* The value of the textarea */
- value?: string
- /* The function to handle the value change */
- onValueChange: (value: ChangeEvent<HTMLTextAreaElement>) => void
- /**
- * If true, include SQL snippets in the message sent to onSubmit
- */
- includeSnippetsInMessage?: boolean
- /**
- * The function to handle the form submission
- */
- onSubmit: (message: string) => void
- /**
- * The function to handle stopping the stream
- */
- onStop?: () => void
- /* The placeholder of the textarea */
- placeholder?: string
- /* SQL snippets to display above the form - can be strings or objects with label and content */
- sqlSnippets?: SqlSnippet[]
- /* Function to handle removing a SQL snippet */
- onRemoveSnippet?: (index: number) => void
- /* Additional class name for the snippets container */
- snippetsClassName?: string
- /* Additional class name for the form wrapper */
- className?: string
- /* If currently editing an existing message */
- isEditing?: boolean
- /* The currently selected AI model */
- selectedModel: AssistantModelId
- /* Callback when a model is chosen */
- onSelectModel: (model: AssistantModelId) => void
- }
- const AssistantChatFormComponent = forwardRef<HTMLFormElement, FormProps>(
- (
- {
- loading = false,
- disabled = false,
- value = '',
- textAreaRef,
- onValueChange,
- onSubmit,
- onStop,
- placeholder,
- sqlSnippets,
- onRemoveSnippet,
- snippetsClassName,
- includeSnippetsInMessage = false,
- className,
- isEditing = false,
- selectedModel,
- onSelectModel,
- ...props
- },
- _ref
- ) => {
- const formRef = useRef<HTMLFormElement>(null)
- const isMobile = useBreakpoint('md')
- const handleSubmit = (event?: FormEvent<HTMLFormElement>) => {
- if (event) event.preventDefault()
- if (disabled || !value || (loading && !isEditing)) return
- let finalMessage = value
- if (includeSnippetsInMessage && sqlSnippets && sqlSnippets.length > 0) {
- const sqlSnippetsString = sqlSnippets
- .map((snippet: SqlSnippet) => '```sql\n' + getSnippetContent(snippet) + '\n```')
- .join('\n')
- finalMessage = [value, sqlSnippetsString].filter(Boolean).join('\n\n')
- }
- onSubmit(finalMessage)
- }
- const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
- if (event.key === 'Enter' && !event.shiftKey) {
- event.preventDefault()
- handleSubmit()
- }
- }
- const canSubmit = !disabled && !loading && !!value
- return (
- <div className="w-full">
- <form
- id="assistant-chat"
- ref={formRef}
- {...props}
- onSubmit={handleSubmit}
- className={cn('relative', className)}
- >
- {sqlSnippets && sqlSnippets.length > 0 && (
- <SnippetRow
- snippets={sqlSnippets}
- onRemoveSnippet={onRemoveSnippet}
- className="absolute top-0 left-0 right-0 px-1.5 py-1.5"
- />
- )}
- <ExpandingTextArea
- autoFocus={!isMobile}
- ref={textAreaRef}
- disabled={disabled}
- className={cn(
- 'text-base md:text-sm pr-10 pb-9 max-h-64',
- sqlSnippets && sqlSnippets.length > 0 && 'pt-10'
- )}
- placeholder={placeholder}
- spellCheck={false}
- rows={3}
- value={value}
- onChange={(event) => onValueChange(event)}
- onKeyDown={handleKeyDown}
- />
- <div className="absolute inset-x-1.5 bottom-1.5 flex items-center justify-between pointer-events-none">
- <div className="pointer-events-auto">
- <ModelSelector selectedModel={selectedModel} onSelectModel={onSelectModel} />
- </div>
- <div className="flex gap-3 items-center pointer-events-auto">
- {loading ? (
- onStop ? (
- <ButtonTooltip
- type="outline"
- aria-label="Stop response"
- icon={<Square fill="currentColor" className="scale-75" />}
- onClick={onStop}
- className="w-7 h-7 rounded-full p-0 text-center flex items-center justify-center"
- tooltip={{ content: { side: 'top', text: 'Stop response' } }}
- />
- ) : (
- <Loader2 size={22} className="animate-spin size-7 text-muted" strokeWidth={1} />
- )
- ) : (
- <ButtonTooltip
- htmlType="submit"
- aria-label="Send message"
- icon={<ArrowUp />}
- disabled={!canSubmit}
- className={cn(
- 'w-7 h-7 rounded-full p-0 text-center flex items-center justify-center',
- !canSubmit ? 'opacity-50' : 'opacity-100'
- )}
- tooltip={{ content: { side: 'top', text: 'Send message' } }}
- />
- )}
- </div>
- </div>
- </form>
- </div>
- )
- }
- )
- AssistantChatFormComponent.displayName = 'AssistantChatFormComponent'
- export const AssistantChatForm = memo(AssistantChatFormComponent)
|