1
- import { Message , OpenAIModel , OpenAIModelID } from '@/types' ;
1
+ import { Message } from '@/types/chat' ;
2
+ import { OpenAIModel , OpenAIModelID } from '@/types/openai' ;
3
+ import { Prompt } from '@/types/prompt' ;
2
4
import { IconPlayerStop , IconRepeat , IconSend } from '@tabler/icons-react' ;
3
5
import { useTranslation } from 'next-i18next' ;
4
6
import {
5
7
FC ,
6
8
KeyboardEvent ,
7
9
MutableRefObject ,
10
+ useCallback ,
8
11
useEffect ,
12
+ useRef ,
9
13
useState ,
10
14
} from 'react' ;
15
+ import { PromptList } from './PromptList' ;
16
+ import { VariableModal } from './VariableModal' ;
11
17
12
18
interface Props {
13
19
messageIsStreaming : boolean ;
14
20
model : OpenAIModel ;
15
21
conversationIsEmpty : boolean ;
22
+ messages : Message [ ] ;
23
+ prompts : Prompt [ ] ;
16
24
onSend : ( message : Message ) => void ;
17
25
onRegenerate : ( ) => void ;
18
26
stopConversationRef : MutableRefObject < boolean > ;
@@ -23,14 +31,28 @@ export const ChatInput: FC<Props> = ({
23
31
messageIsStreaming,
24
32
model,
25
33
conversationIsEmpty,
34
+ messages,
35
+ prompts,
26
36
onSend,
27
37
onRegenerate,
28
38
stopConversationRef,
29
39
textareaRef,
30
40
} ) => {
31
41
const { t } = useTranslation ( 'chat' ) ;
42
+
32
43
const [ content , setContent ] = useState < string > ( ) ;
33
44
const [ isTyping , setIsTyping ] = useState < boolean > ( false ) ;
45
+ const [ showPromptList , setShowPromptList ] = useState ( false ) ;
46
+ const [ activePromptIndex , setActivePromptIndex ] = useState ( 0 ) ;
47
+ const [ promptInputValue , setPromptInputValue ] = useState ( '' ) ;
48
+ const [ variables , setVariables ] = useState < string [ ] > ( [ ] ) ;
49
+ const [ isModalVisible , setIsModalVisible ] = useState ( false ) ;
50
+
51
+ const promptListRef = useRef < HTMLUListElement | null > ( null ) ;
52
+
53
+ const filteredPrompts = prompts . filter ( ( prompt ) =>
54
+ prompt . name . toLowerCase ( ) . includes ( promptInputValue . toLowerCase ( ) ) ,
55
+ ) ;
34
56
35
57
const handleChange = ( e : React . ChangeEvent < HTMLTextAreaElement > ) => {
36
58
const value = e . target . value ;
@@ -47,6 +69,7 @@ export const ChatInput: FC<Props> = ({
47
69
}
48
70
49
71
setContent ( value ) ;
72
+ updatePromptListVisibility ( value ) ;
50
73
} ;
51
74
52
75
const handleSend = ( ) => {
@@ -67,6 +90,13 @@ export const ChatInput: FC<Props> = ({
67
90
}
68
91
} ;
69
92
93
+ const handleStopConversation = ( ) => {
94
+ stopConversationRef . current = true ;
95
+ setTimeout ( ( ) => {
96
+ stopConversationRef . current = false ;
97
+ } , 1000 ) ;
98
+ } ;
99
+
70
100
const isMobile = ( ) => {
71
101
const userAgent =
72
102
typeof window . navigator === 'undefined' ? '' : navigator . userAgent ;
@@ -75,15 +105,106 @@ export const ChatInput: FC<Props> = ({
75
105
return mobileRegex . test ( userAgent ) ;
76
106
} ;
77
107
108
+ const handleInitModal = ( ) => {
109
+ const selectedPrompt = filteredPrompts [ activePromptIndex ] ;
110
+ setContent ( ( prevContent ) => {
111
+ const newContent = prevContent ?. replace ( / \/ \w * $ / , selectedPrompt . content ) ;
112
+ return newContent ;
113
+ } ) ;
114
+ handlePromptSelect ( selectedPrompt ) ;
115
+ setShowPromptList ( false ) ;
116
+ } ;
117
+
78
118
const handleKeyDown = ( e : KeyboardEvent < HTMLTextAreaElement > ) => {
79
- if ( ! isTyping ) {
80
- if ( e . key === 'Enter' && ! e . shiftKey && ! isMobile ( ) ) {
119
+ if ( showPromptList ) {
120
+ if ( e . key === 'ArrowDown' ) {
81
121
e . preventDefault ( ) ;
82
- handleSend ( ) ;
122
+ setActivePromptIndex ( ( prevIndex ) =>
123
+ prevIndex < prompts . length - 1 ? prevIndex + 1 : prevIndex ,
124
+ ) ;
125
+ } else if ( e . key === 'ArrowUp' ) {
126
+ e . preventDefault ( ) ;
127
+ setActivePromptIndex ( ( prevIndex ) =>
128
+ prevIndex > 0 ? prevIndex - 1 : prevIndex ,
129
+ ) ;
130
+ } else if ( e . key === 'Tab' ) {
131
+ e . preventDefault ( ) ;
132
+ setActivePromptIndex ( ( prevIndex ) =>
133
+ prevIndex < prompts . length - 1 ? prevIndex + 1 : 0 ,
134
+ ) ;
135
+ } else if ( e . key === 'Enter' ) {
136
+ e . preventDefault ( ) ;
137
+ handleInitModal ( ) ;
138
+ } else if ( e . key === 'Escape' ) {
139
+ e . preventDefault ( ) ;
140
+ setShowPromptList ( false ) ;
141
+ } else {
142
+ setActivePromptIndex ( 0 ) ;
83
143
}
144
+ } else if ( e . key === 'Enter' && ! isMobile ( ) && ! e . shiftKey ) {
145
+ e . preventDefault ( ) ;
146
+ handleSend ( ) ;
147
+ }
148
+ } ;
149
+
150
+ const parseVariables = ( content : string ) => {
151
+ const regex = / { { ( .* ?) } } / g;
152
+ const foundVariables = [ ] ;
153
+ let match ;
154
+
155
+ while ( ( match = regex . exec ( content ) ) !== null ) {
156
+ foundVariables . push ( match [ 1 ] ) ;
157
+ }
158
+
159
+ return foundVariables ;
160
+ } ;
161
+
162
+ const updatePromptListVisibility = useCallback ( ( text : string ) => {
163
+ const match = text . match ( / \/ \w * $ / ) ;
164
+
165
+ if ( match ) {
166
+ setShowPromptList ( true ) ;
167
+ setPromptInputValue ( match [ 0 ] . slice ( 1 ) ) ;
168
+ } else {
169
+ setShowPromptList ( false ) ;
170
+ setPromptInputValue ( '' ) ;
171
+ }
172
+ } , [ ] ) ;
173
+
174
+ const handlePromptSelect = ( prompt : Prompt ) => {
175
+ const parsedVariables = parseVariables ( prompt . content ) ;
176
+ setVariables ( parsedVariables ) ;
177
+
178
+ if ( parsedVariables . length > 0 ) {
179
+ setIsModalVisible ( true ) ;
180
+ } else {
181
+ setContent ( ( prevContent ) => {
182
+ const updatedContent = prevContent ?. replace ( / \/ \w * $ / , prompt . content ) ;
183
+ return updatedContent ;
184
+ } ) ;
185
+ updatePromptListVisibility ( prompt . content ) ;
186
+ }
187
+ } ;
188
+
189
+ const handleSubmit = ( updatedVariables : string [ ] ) => {
190
+ const newContent = content ?. replace ( / { { ( .* ?) } } / g, ( match , variable ) => {
191
+ const index = variables . indexOf ( variable ) ;
192
+ return updatedVariables [ index ] ;
193
+ } ) ;
194
+
195
+ setContent ( newContent ) ;
196
+
197
+ if ( textareaRef && textareaRef . current ) {
198
+ textareaRef . current . focus ( ) ;
84
199
}
85
200
} ;
86
201
202
+ useEffect ( ( ) => {
203
+ if ( promptListRef . current ) {
204
+ promptListRef . current . scrollTop = activePromptIndex * 30 ;
205
+ }
206
+ } , [ activePromptIndex ] ) ;
207
+
87
208
useEffect ( ( ) => {
88
209
if ( textareaRef && textareaRef . current ) {
89
210
textareaRef . current . style . height = 'inherit' ;
@@ -94,19 +215,29 @@ export const ChatInput: FC<Props> = ({
94
215
}
95
216
} , [ content ] ) ;
96
217
97
- function handleStopConversation ( ) {
98
- stopConversationRef . current = true ;
99
- setTimeout ( ( ) => {
100
- stopConversationRef . current = false ;
101
- } , 1000 ) ;
102
- }
218
+ useEffect ( ( ) => {
219
+ const handleOutsideClick = ( e : MouseEvent ) => {
220
+ if (
221
+ promptListRef . current &&
222
+ ! promptListRef . current . contains ( e . target as Node )
223
+ ) {
224
+ setShowPromptList ( false ) ;
225
+ }
226
+ } ;
227
+
228
+ window . addEventListener ( 'click' , handleOutsideClick ) ;
229
+
230
+ return ( ) => {
231
+ window . removeEventListener ( 'click' , handleOutsideClick ) ;
232
+ } ;
233
+ } , [ ] ) ;
103
234
104
235
return (
105
236
< div className = "absolute bottom-0 left-0 w-full border-transparent bg-gradient-to-b from-transparent via-white to-white pt-6 dark:border-white/20 dark:via-[#343541] dark:to-[#343541] md:pt-2" >
106
237
< div className = "stretch mx-2 mt-4 flex flex-row gap-3 last:mb-2 md:mx-4 md:mt-[52px] md:last:mb-6 lg:mx-auto lg:max-w-3xl" >
107
238
{ messageIsStreaming && (
108
239
< button
109
- className = "absolute - top-2 left-0 right-0 mx-auto w-fit rounded border border-neutral-200 bg-white py-2 px-4 text-black dark:border-neutral-600 dark:bg-[#343541] dark:text-white md:top-0"
240
+ className = "absolute top-2 left-0 right-0 mx-auto mt-2 w-fit rounded border border-neutral-200 bg-white py-2 px-4 text-black hover:opacity-50 dark:border-neutral-600 dark:bg-[#343541] dark:text-white md:top-0"
110
241
onClick = { handleStopConversation }
111
242
>
112
243
< IconPlayerStop size = { 16 } className = "mb-[2px] inline-block" /> { ' ' }
@@ -116,18 +247,18 @@ export const ChatInput: FC<Props> = ({
116
247
117
248
{ ! messageIsStreaming && ! conversationIsEmpty && (
118
249
< button
119
- className = "absolute -top-2 left-0 right-0 mx-auto w-fit rounded border border-neutral-200 bg-white py-2 px-4 text-black dark:border-neutral-600 dark:bg-[#343541] dark:text-white md:top-0"
250
+ className = "absolute left-0 right-0 mx-auto mt-2 w-fit rounded border border-neutral-200 bg-white py-2 px-4 text-black hover:opacity-50 dark:border-neutral-600 dark:bg-[#343541] dark:text-white md:top-0"
120
251
onClick = { onRegenerate }
121
252
>
122
253
< IconRepeat size = { 16 } className = "mb-[2px] inline-block" /> { ' ' }
123
254
{ t ( 'Regenerate response' ) }
124
255
</ button >
125
256
) }
126
257
127
- < div className = "relative flex w-full flex-grow flex-col rounded-md border border-black/10 bg-white py-2 shadow-[0_0_10px_rgba(0,0,0,0.10)] dark:border-gray-900/50 dark:bg-[#40414F] dark:text-white dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] md:py-3 md:pl-4" >
258
+ < div className = "relative mx-2 flex w-full flex-grow flex-col rounded-md border border-black/10 bg-white py-2 shadow-[0_0_10px_rgba(0,0,0,0.10)] dark:border-gray-900/50 dark:bg-[#40414F] dark:text-white dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] sm:mx-4 md:py-3 md:pl-4" >
128
259
< textarea
129
260
ref = { textareaRef }
130
- className = "m-0 w-full resize-none border-0 bg-transparent p-0 pr-7 pl-2 text-black outline-none focus:ring-0 focus-visible:ring-0 dark:bg-transparent dark:text-white md:pl-0"
261
+ className = "m-0 w-full resize-none border-0 bg-transparent p-0 pr-8 pl-2 text-black outline-none focus:ring-0 focus-visible:ring-0 dark:bg-transparent dark:text-white md:pl-0"
131
262
style = { {
132
263
resize : 'none' ,
133
264
bottom : `${ textareaRef ?. current ?. scrollHeight } px` ,
@@ -138,7 +269,9 @@ export const ChatInput: FC<Props> = ({
138
269
: 'hidden'
139
270
} `,
140
271
} }
141
- placeholder = { t ( 'Type a message...' ) || '' }
272
+ placeholder = {
273
+ t ( 'Type a message or type "/" to select a prompt...' ) || ''
274
+ }
142
275
value = { content }
143
276
rows = { 1 }
144
277
onCompositionStart = { ( ) => setIsTyping ( true ) }
@@ -153,9 +286,30 @@ export const ChatInput: FC<Props> = ({
153
286
>
154
287
< IconSend size = { 16 } className = "opacity-60" />
155
288
</ button >
289
+
290
+ { showPromptList && prompts . length > 0 && (
291
+ < div className = "absolute bottom-12 w-full" >
292
+ < PromptList
293
+ activePromptIndex = { activePromptIndex }
294
+ prompts = { filteredPrompts }
295
+ onSelect = { handleInitModal }
296
+ onMouseOver = { setActivePromptIndex }
297
+ promptListRef = { promptListRef }
298
+ />
299
+ </ div >
300
+ ) }
301
+
302
+ { isModalVisible && (
303
+ < VariableModal
304
+ prompt = { prompts [ activePromptIndex ] }
305
+ variables = { variables }
306
+ onSubmit = { handleSubmit }
307
+ onClose = { ( ) => setIsModalVisible ( false ) }
308
+ />
309
+ ) }
156
310
</ div >
157
311
</ div >
158
- < div className = "px-3 pt-2 pb-3 text-center text-xs text-black/50 dark:text-white/50 md:px-4 md:pt-3 md:pb-6" >
312
+ < div className = "px-3 pt-2 pb-3 text-center text-[12px] text-black/50 dark:text-white/50 md:px-4 md:pt-3 md:pb-6" >
159
313
< a
160
314
href = "https://github.com/mckaywrigley/chatbot-ui"
161
315
target = "_blank"
0 commit comments