AssistantChatForm.tsx 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. import { useBreakpoint } from 'common'
  2. import { ArrowUp, Loader2, Square } from 'lucide-react'
  3. import { ChangeEvent, FormEvent, forwardRef, KeyboardEvent, memo, useRef } from 'react'
  4. import { ExpandingTextArea } from 'ui'
  5. import { cn } from 'ui/src/lib/utils'
  6. import { ButtonTooltip } from '../ButtonTooltip'
  7. import { type SqlSnippet } from './AIAssistant.types'
  8. import { ModelSelector } from './ModelSelector'
  9. import { getSnippetContent, SnippetRow } from './SnippetRow'
  10. import type { AssistantModelId } from '@/lib/ai/model.utils'
  11. export interface FormProps {
  12. /* The ref for the textarea, optional. Exposed for the CommandsPopover to attach events. */
  13. textAreaRef?: React.RefObject<HTMLTextAreaElement | null>
  14. /* The loading state of the form */
  15. loading: boolean
  16. /* The disabled state of the form */
  17. disabled?: boolean
  18. /* The value of the textarea */
  19. value?: string
  20. /* The function to handle the value change */
  21. onValueChange: (value: ChangeEvent<HTMLTextAreaElement>) => void
  22. /**
  23. * If true, include SQL snippets in the message sent to onSubmit
  24. */
  25. includeSnippetsInMessage?: boolean
  26. /**
  27. * The function to handle the form submission
  28. */
  29. onSubmit: (message: string) => void
  30. /**
  31. * The function to handle stopping the stream
  32. */
  33. onStop?: () => void
  34. /* The placeholder of the textarea */
  35. placeholder?: string
  36. /* SQL snippets to display above the form - can be strings or objects with label and content */
  37. sqlSnippets?: SqlSnippet[]
  38. /* Function to handle removing a SQL snippet */
  39. onRemoveSnippet?: (index: number) => void
  40. /* Additional class name for the snippets container */
  41. snippetsClassName?: string
  42. /* Additional class name for the form wrapper */
  43. className?: string
  44. /* If currently editing an existing message */
  45. isEditing?: boolean
  46. /* The currently selected AI model */
  47. selectedModel: AssistantModelId
  48. /* Callback when a model is chosen */
  49. onSelectModel: (model: AssistantModelId) => void
  50. }
  51. const AssistantChatFormComponent = forwardRef<HTMLFormElement, FormProps>(
  52. (
  53. {
  54. loading = false,
  55. disabled = false,
  56. value = '',
  57. textAreaRef,
  58. onValueChange,
  59. onSubmit,
  60. onStop,
  61. placeholder,
  62. sqlSnippets,
  63. onRemoveSnippet,
  64. snippetsClassName,
  65. includeSnippetsInMessage = false,
  66. className,
  67. isEditing = false,
  68. selectedModel,
  69. onSelectModel,
  70. ...props
  71. },
  72. _ref
  73. ) => {
  74. const formRef = useRef<HTMLFormElement>(null)
  75. const isMobile = useBreakpoint('md')
  76. const handleSubmit = (event?: FormEvent<HTMLFormElement>) => {
  77. if (event) event.preventDefault()
  78. if (disabled || !value || (loading && !isEditing)) return
  79. let finalMessage = value
  80. if (includeSnippetsInMessage && sqlSnippets && sqlSnippets.length > 0) {
  81. const sqlSnippetsString = sqlSnippets
  82. .map((snippet: SqlSnippet) => '```sql\n' + getSnippetContent(snippet) + '\n```')
  83. .join('\n')
  84. finalMessage = [value, sqlSnippetsString].filter(Boolean).join('\n\n')
  85. }
  86. onSubmit(finalMessage)
  87. }
  88. const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
  89. if (event.key === 'Enter' && !event.shiftKey) {
  90. event.preventDefault()
  91. handleSubmit()
  92. }
  93. }
  94. const canSubmit = !disabled && !loading && !!value
  95. return (
  96. <div className="w-full">
  97. <form
  98. id="assistant-chat"
  99. ref={formRef}
  100. {...props}
  101. onSubmit={handleSubmit}
  102. className={cn('relative', className)}
  103. >
  104. {sqlSnippets && sqlSnippets.length > 0 && (
  105. <SnippetRow
  106. snippets={sqlSnippets}
  107. onRemoveSnippet={onRemoveSnippet}
  108. className="absolute top-0 left-0 right-0 px-1.5 py-1.5"
  109. />
  110. )}
  111. <ExpandingTextArea
  112. autoFocus={!isMobile}
  113. ref={textAreaRef}
  114. disabled={disabled}
  115. className={cn(
  116. 'text-base md:text-sm pr-10 pb-9 max-h-64',
  117. sqlSnippets && sqlSnippets.length > 0 && 'pt-10'
  118. )}
  119. placeholder={placeholder}
  120. spellCheck={false}
  121. rows={3}
  122. value={value}
  123. onChange={(event) => onValueChange(event)}
  124. onKeyDown={handleKeyDown}
  125. />
  126. <div className="absolute inset-x-1.5 bottom-1.5 flex items-center justify-between pointer-events-none">
  127. <div className="pointer-events-auto">
  128. <ModelSelector selectedModel={selectedModel} onSelectModel={onSelectModel} />
  129. </div>
  130. <div className="flex gap-3 items-center pointer-events-auto">
  131. {loading ? (
  132. onStop ? (
  133. <ButtonTooltip
  134. type="outline"
  135. aria-label="Stop response"
  136. icon={<Square fill="currentColor" className="scale-75" />}
  137. onClick={onStop}
  138. className="w-7 h-7 rounded-full p-0 text-center flex items-center justify-center"
  139. tooltip={{ content: { side: 'top', text: 'Stop response' } }}
  140. />
  141. ) : (
  142. <Loader2 size={22} className="animate-spin size-7 text-muted" strokeWidth={1} />
  143. )
  144. ) : (
  145. <ButtonTooltip
  146. htmlType="submit"
  147. aria-label="Send message"
  148. icon={<ArrowUp />}
  149. disabled={!canSubmit}
  150. className={cn(
  151. 'w-7 h-7 rounded-full p-0 text-center flex items-center justify-center',
  152. !canSubmit ? 'opacity-50' : 'opacity-100'
  153. )}
  154. tooltip={{ content: { side: 'top', text: 'Send message' } }}
  155. />
  156. )}
  157. </div>
  158. </div>
  159. </form>
  160. </div>
  161. )
  162. }
  163. )
  164. AssistantChatFormComponent.displayName = 'AssistantChatFormComponent'
  165. export const AssistantChatForm = memo(AssistantChatFormComponent)