11import React , { useCallback , useEffect , useRef , useState } from "react" ;
22import { createDecorators , type EventEmitter } from "@next-core/element" ;
33import { ReactNextElement , wrapBrick } from "@next-core/react-element" ;
4- import {
5- TextareaAutoResize ,
6- type TextareaAutoResizeRef ,
7- } from "@next-shared/form" ;
4+ import { TextareaAutoResize } from "@next-shared/form" ;
85import "@next-core/theme" ;
96import { initializeI18n } from "@next-core/i18n" ;
107import ResizeObserver from "resize-observer-polyfill" ;
118import type {
129 GeneralIcon ,
1310 GeneralIconProps ,
1411} from "@next-bricks/icons/general-icon" ;
12+ import type {
13+ ActionsEvents ,
14+ ActionsEventsMapping ,
15+ ActionsProps ,
16+ EoActions ,
17+ } from "@next-bricks/basic/actions" ;
1518import classNames from "classnames" ;
1619import { K , NS , locales , t } from "./i18n.js" ;
1720import type { IconButton , IconButtonProps } from "../icon-button" ;
@@ -25,6 +28,14 @@ import {
2528} from "../shared/FileUpload/UploadButton.js" ;
2629import type { ChatPayload , UploadOptions } from "../shared/interfaces.js" ;
2730import GlobalDragOverlay from "../shared/FileUpload/GlobalDragOverlay.js" ;
31+ import {
32+ MAX_SHOWN_COMMANDS ,
33+ useChatCompletions ,
34+ type ActionWithSubCommands ,
35+ type AIEmployee ,
36+ type Command ,
37+ } from "../shared/ChatCompletions/useChatCompletions.js" ;
38+ import { createPortal } from "react-dom" ;
2839
2940initializeI18n ( NS , locales ) ;
3041
@@ -45,6 +56,17 @@ const WrappedIconButton = wrapBrick<IconButton, IconButtonProps>(
4556 "ai-portal.icon-button"
4657) ;
4758
59+ export const WrappedActions = wrapBrick <
60+ EoActions ,
61+ ActionsProps & { activeKeys ?: ( string | number ) [ ] ; footerTips ?: string } ,
62+ ActionsEvents ,
63+ ActionsEventsMapping
64+ > ( "eo-actions" , {
65+ onActionClick : "action.click" ,
66+ onItemDragEnd : "item.drag.end" ,
67+ onItemDragStart : "item.drag.start" ,
68+ } ) ;
69+
4870const { defineElement, property, event } = createDecorators ( ) ;
4971
5072export interface ChatInputProps {
@@ -54,6 +76,9 @@ export interface ChatInputProps {
5476 supportsTerminate ?: boolean ;
5577 terminating ?: boolean ;
5678 uploadOptions ?: UploadOptions ;
79+ aiEmployees ?: AIEmployee [ ] ;
80+ commands ?: Command [ ] ;
81+ suggestionsPlacement ?: "top" | "bottom" ;
5782}
5883
5984export interface ChatInputEvents {
@@ -67,7 +92,7 @@ export interface ChatInputMapEvents {
6792}
6893
6994/**
70- * 构件 `ai-portal.chat-input`
95+ * 小型聊天输入框,用于对话等页面
7196 */
7297export
7398@defineElement ( "ai-portal.chat-input" , {
@@ -95,6 +120,18 @@ class ChatInput extends ReactNextElement implements ChatInputProps {
95120 @property ( { attribute : false } )
96121 accessor uploadOptions : UploadOptions | undefined ;
97122
123+ @property ( { attribute : false } )
124+ accessor aiEmployees : AIEmployee [ ] | undefined ;
125+
126+ @property ( { attribute : false } )
127+ accessor commands : Command [ ] | undefined ;
128+
129+ /**
130+ * @default "bottom"
131+ */
132+ @property ( )
133+ accessor suggestionsPlacement : "top" | "bottom" | undefined ;
134+
98135 /**
99136 * @deprecated Use `chat.submit` event instead
100137 */
@@ -128,35 +165,42 @@ class ChatInput extends ReactNextElement implements ChatInputProps {
128165 supportsTerminate = { this . supportsTerminate }
129166 terminating = { this . terminating }
130167 uploadOptions = { this . uploadOptions }
168+ aiEmployees = { this . aiEmployees }
169+ commands = { this . commands }
170+ suggestionsPlacement = { this . suggestionsPlacement }
131171 onMessageSubmit = { this . #handleMessageSubmit}
132172 onChatSubmit = { this . #handleChatSubmit}
133173 onTerminate = { this . #handleTerminate}
174+ root = { this }
134175 />
135176 ) ;
136177 }
137178}
138179
139180interface ChatInputComponentProps extends ChatInputProps {
181+ root : HTMLElement ;
140182 onMessageSubmit : ( value : string ) => void ;
141183 onChatSubmit : ( payload : ChatPayload ) => void ;
142184 onTerminate : ( ) => void ;
143185}
144186
145187function ChatInputComponent ( {
188+ root,
146189 placeholder,
147190 autoFocus,
148191 submitDisabled,
149192 supportsTerminate,
150193 terminating,
151194 uploadOptions,
195+ aiEmployees,
196+ commands,
197+ suggestionsPlacement,
152198 onMessageSubmit,
153199 onChatSubmit,
154200 onTerminate,
155201} : ChatInputComponentProps ) {
156202 const containerRef = useRef < HTMLDivElement > ( null ) ;
157- const textareaRef = useRef < TextareaAutoResizeRef > ( null ) ;
158- const [ value , setValue ] = useState ( "" ) ;
159- const valueRef = useRef ( "" ) ;
203+
160204 const [ wrap , setWrap ] = useState ( false ) ;
161205 const uploadEnabled = uploadOptions ?. enabled ;
162206 const uploadAccept = uploadOptions ?. accept ;
@@ -173,6 +217,38 @@ function ChatInputComponent({
173217 } = useFilesUploading ( uploadOptions ) ;
174218 const uploadButtonRef = useRef < UploadButtonRef > ( null ) ;
175219
220+ const {
221+ textareaRef,
222+ valueRef,
223+ value,
224+ setValue,
225+ selectionRef,
226+
227+ mentioned,
228+ mentionedText,
229+ mentionPopover,
230+ mentionOverlay,
231+ mentionActiveKeys,
232+ handleMention,
233+
234+ command,
235+ commandText,
236+ commandPrefix,
237+ commandPopover,
238+ commandOverlay,
239+ commandActiveKeys,
240+ handleSelectCommand,
241+
242+ handleChange,
243+ handleKeyDown,
244+ } = useChatCompletions ( {
245+ aiEmployees,
246+ commands,
247+ root,
248+ hasFiles,
249+ placement : suggestionsPlacement ,
250+ } ) ;
251+
176252 useEffect ( ( ) => {
177253 if ( ! uploadEnabled ) {
178254 resetFiles ( ) ;
@@ -183,48 +259,58 @@ function ChatInputComponent({
183259 if ( autoFocus && ! submitDisabled ) {
184260 textareaRef . current ?. focus ( ) ;
185261 }
186- } , [ autoFocus , submitDisabled ] ) ;
262+ } , [ autoFocus , submitDisabled , textareaRef ] ) ;
187263
188- const onBeforeSubmit = useCallback (
264+ const sendDisabled =
265+ ! value ||
266+ ! allFilesDone ||
267+ ( ! ! mentionedText && value . length <= mentionedText . length ) ||
268+ ( ! ! commandText && value . length <= commandText . length ) ;
269+
270+ const doSubmit = useCallback (
189271 ( value : string ) => {
190- if ( submitDisabled || ! value || ! allFilesDone ) {
272+ if ( sendDisabled ) {
191273 return ;
192274 }
193-
194- onMessageSubmit ( value ) ;
195- onChatSubmit ( { content : value , files : fileInfos } ) ;
275+ const content = value . slice ( commandText . length || mentionedText . length ) ;
276+ onMessageSubmit ( content ) ;
277+ onChatSubmit ( {
278+ content,
279+ files : fileInfos ,
280+ cmd : command ,
281+ aiEmployeeId : mentioned ,
282+ } ) ;
196283 valueRef . current = "" ;
197284 setValue ( "" ) ;
198285 resetFiles ( ) ;
199286 } ,
200287 [
201- allFilesDone ,
202- submitDisabled ,
288+ sendDisabled ,
203289 onMessageSubmit ,
204290 onChatSubmit ,
205291 fileInfos ,
206292 resetFiles ,
293+ valueRef ,
294+ setValue ,
295+ mentioned ,
296+ mentionedText ,
297+ command ,
298+ commandText ,
207299 ]
208300 ) ;
209301
210302 const handleSubmit = useCallback (
211303 ( e : React . FormEvent < HTMLTextAreaElement > ) => {
212- onBeforeSubmit ( e . currentTarget . value ) ;
213- } ,
214- [ onBeforeSubmit ]
215- ) ;
216-
217- const handleChange = useCallback (
218- ( e : React . ChangeEvent < HTMLTextAreaElement > ) => {
219- valueRef . current = e . target . value ;
220- setValue ( e . target . value ) ;
304+ if ( e . currentTarget . value ) {
305+ doSubmit ( e . currentTarget . value ) ;
306+ }
221307 } ,
222- [ ]
308+ [ doSubmit ]
223309 ) ;
224310
225311 const handleSubmitClick = useCallback ( ( ) => {
226- onBeforeSubmit ( valueRef . current ) ;
227- } , [ onBeforeSubmit ] ) ;
312+ doSubmit ( valueRef . current ) ;
313+ } , [ doSubmit , valueRef ] ) ;
228314
229315 // istanbul ignore next
230316 useEffect ( ( ) => {
@@ -263,24 +349,27 @@ function ChatInputComponent({
263349 }
264350 } , [ value , hasFiles ] ) ;
265351
266- const handleContainerClick = useCallback ( ( e : React . MouseEvent ) => {
267- for ( const item of e . nativeEvent . composedPath ( ) ) {
268- if (
269- item instanceof HTMLTextAreaElement ||
270- item instanceof HTMLButtonElement
271- ) {
272- return ;
352+ const handleContainerClick = useCallback (
353+ ( e : React . MouseEvent ) => {
354+ for ( const item of e . nativeEvent . composedPath ( ) ) {
355+ if (
356+ item instanceof HTMLTextAreaElement ||
357+ item instanceof HTMLButtonElement
358+ ) {
359+ return ;
360+ }
273361 }
274- }
275- textareaRef . current ?. focus ( ) ;
276- } , [ ] ) ;
362+ textareaRef . current ?. focus ( ) ;
363+ } ,
364+ [ textareaRef ]
365+ ) ;
277366
278367 const onFilesDropped = useCallback (
279368 ( files : File [ ] ) => {
280369 appendFiles ( files ) ;
281370 textareaRef . current ?. focus ( ) ;
282371 } ,
283- [ appendFiles ]
372+ [ appendFiles , textareaRef ]
284373 ) ;
285374
286375 return (
@@ -299,8 +388,10 @@ function ChatInputComponent({
299388 autoResize
300389 placeholder = { placeholder }
301390 submitWhen = "enter-without-shift"
391+ desiredSelectionRef = { selectionRef }
302392 onSubmit = { handleSubmit }
303393 onChange = { handleChange }
394+ onKeyDown = { handleKeyDown }
304395 onPaste = { paste }
305396 style = { {
306397 paddingTop : hasFiles ? 78 : 8 ,
@@ -319,6 +410,40 @@ function ChatInputComponent({
319410 } }
320411 />
321412 ) }
413+ { mentionOverlay ?. map ( ( overlay , index ) => (
414+ < div key = { index } className = "mention-overlay" style = { overlay } />
415+ ) ) }
416+ { commandOverlay ?. map ( ( overlay , index ) => (
417+ < div key = { index } className = "mention-overlay" style = { overlay } />
418+ ) ) }
419+ { mentionPopover &&
420+ createPortal (
421+ < WrappedActions
422+ actions = { mentionPopover . actions }
423+ style = { mentionPopover . style }
424+ activeKeys = { mentionActiveKeys ! }
425+ footerTips = { t ( K . COMMAND_TIPS ) }
426+ onActionClick = { ( e ) => handleMention ( e . detail ) }
427+ /> ,
428+ document . body
429+ ) }
430+ { commandPopover &&
431+ createPortal (
432+ < WrappedActions
433+ actions = { commandPopover . actions }
434+ style = { commandPopover . style }
435+ activeKeys = { commandActiveKeys ! }
436+ footerTips = {
437+ commandPrefix === "/"
438+ ? t ( K . COMMAND_TIPS )
439+ : t ( K . SEARCH_COMMANDS_TIPS , { count : MAX_SHOWN_COMMANDS } )
440+ }
441+ onActionClick = { ( e ) =>
442+ handleSelectCommand ( e . detail as ActionWithSubCommands )
443+ }
444+ /> ,
445+ document . body
446+ ) }
322447 </ div >
323448 < div className = "toolbar" >
324449 { uploadEnabled ? (
0 commit comments