From 3ea5402f9f1b9e91825b069c8d7e672241486eaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Bul=C3=A1nek?= Date: Tue, 22 Oct 2024 10:13:41 +0200 Subject: [PATCH] feat(assistants): add prompt suggestions (#43) --- .../builder/AssistantBuilderProvider.tsx | 14 +- .../assistants/builder/AssistantForm.tsx | 2 +- .../StarterQuestionsTextArea.module.scss | 90 ++++++- .../builder/StarterQuestionsTextArea.tsx | 153 ++++++++++- src/modules/assistants/types.ts | 10 +- src/modules/assistants/utils.ts | 34 ++- src/modules/chat/EmptyChatView.tsx | 2 +- src/modules/chat/layout/InputBar.tsx | 59 ++-- .../chat/layout/PromptSuggestions.module.scss | 87 ++++++ src/modules/chat/layout/PromptSuggestions.tsx | 251 ++++++++++++++++++ src/utils/formUtils.ts | 36 +++ 11 files changed, 702 insertions(+), 36 deletions(-) create mode 100644 src/modules/chat/layout/PromptSuggestions.module.scss create mode 100644 src/modules/chat/layout/PromptSuggestions.tsx create mode 100644 src/utils/formUtils.ts diff --git a/src/modules/assistants/builder/AssistantBuilderProvider.tsx b/src/modules/assistants/builder/AssistantBuilderProvider.tsx index 5d57a70d..6b4f4797 100644 --- a/src/modules/assistants/builder/AssistantBuilderProvider.tsx +++ b/src/modules/assistants/builder/AssistantBuilderProvider.tsx @@ -41,7 +41,11 @@ import { } from '../icons/AssistantBaseIcon'; import { readAssistantQuery } from '../queries'; import { Assistant, AssistantMetadata } from '../types'; -import { getAssistantFromAssistantResult } from '../utils'; +import { + decodeStarterQuestionsMetadata, + encodeStarterQuestionsMetadata, + getAssistantFromAssistantResult, +} from '../utils'; import { useSaveAssistant } from './useSaveAssistant'; export type AssistantFormValues = { @@ -55,8 +59,11 @@ export type AssistantFormValues = { tools: { type: ToolType; id: string }[]; vectorStoreId?: string; model?: AssistantModel; + starterQuestions?: StarterQuestion[]; }; +export type StarterQuestion = { id: string; question: string }; + export interface AssistantBuilderContextValue { assistant: Assistant | null; formReturn: UseFormReturn; @@ -173,6 +180,7 @@ export function AssistantBuilderProvider({ icon, vectorStoreId, model, + starterQuestions, }: AssistantFormValues) => { const tools: AssistantTools = toolsValue .map(({ type, id }) => { @@ -201,6 +209,9 @@ export function AssistantBuilderProvider({ metadata: encodeMetadata({ icon: icon.name, color: icon.color, + ...(starterQuestions + ? encodeStarterQuestionsMetadata(starterQuestions) + : {}), }), model, }, @@ -293,6 +304,7 @@ function formValuesFromAssistant( vectorStoreId: assistant?.tool_resources?.file_search?.vector_store_ids?.at(0), model: assistant?.model as AssistantModel, + starterQuestions: decodeStarterQuestionsMetadata(assistant?.metadata), }; } diff --git a/src/modules/assistants/builder/AssistantForm.tsx b/src/modules/assistants/builder/AssistantForm.tsx index 095204de..f8d3fd2f 100644 --- a/src/modules/assistants/builder/AssistantForm.tsx +++ b/src/modules/assistants/builder/AssistantForm.tsx @@ -72,7 +72,7 @@ export function AssistantForm() { - {/* */} + diff --git a/src/modules/assistants/builder/StarterQuestionsTextArea.module.scss b/src/modules/assistants/builder/StarterQuestionsTextArea.module.scss index 8380cb3d..f5f4a36d 100644 --- a/src/modules/assistants/builder/StarterQuestionsTextArea.module.scss +++ b/src/modules/assistants/builder/StarterQuestionsTextArea.module.scss @@ -22,17 +22,25 @@ } } +.addHolder { + display: flex; + align-items: stretch; +} + .textarea { + position: relative; + flex-grow: 1; + z-index: 1; + min-inline-size: 0; &::after, > textarea { @include type-style(body-01); - padding: rem(13px) rem(63px) rem(13px) rem(15px); + padding: rem(13px) rem(15px); border-radius: $spacing-03; - border: 1px solid $border-subtle-00; + min-inline-size: 0; } > textarea { color: $text-primary; - background-color: transparent; &::placeholder { color: $text-placeholder; } @@ -42,3 +50,79 @@ } } } + +.addTextarea { + &::after, + > textarea { + border-start-end-radius: 0; + border-end-end-radius: 0; + border: 1px solid $border-subtle-00; + } + > textarea { + background-color: transparent; + } +} + +.button { + :global(.#{$prefix}--tooltip-trigger__wrapper) { + block-size: 100%; + } + :global(.#{$prefix}--btn) { + border-start-start-radius: 0; + border-end-start-radius: 0; + block-size: 100%; + padding-block: rem(15px); + > svg { + margin: 0; + } + } +} + +.addButton { + :global(.#{$prefix}--btn) { + border-inline-start: 0; + border-color: $border-subtle-00; + align-items: flex-end; + color: $text-dark; + &:hover { + background-color: $border-subtle-00; + color: $text-muted; + } + &:disabled { + color: $border-disabled; + } + } +} + +.list { + display: flex; + flex-direction: column; + row-gap: $spacing-03; + .addHolder + & { + margin-block-start: $spacing-03; + } +} + +.item { + position: relative; +} + +.itemTextarea { + &::after, + > textarea { + border: 1px solid $border-subtle-01; + padding-inline-end: rem(47px); + } + > textarea { + background-color: $border-subtle-01; + } +} + +.removeButton { + &:global(.#{$prefix}--popover-container) { + position: absolute; + inset-block-start: 0; + inset-inline-end: 0; + inset-block-end: 0; + } +} diff --git a/src/modules/assistants/builder/StarterQuestionsTextArea.tsx b/src/modules/assistants/builder/StarterQuestionsTextArea.tsx index 4e40179a..95f45d7c 100644 --- a/src/modules/assistants/builder/StarterQuestionsTextArea.tsx +++ b/src/modules/assistants/builder/StarterQuestionsTextArea.tsx @@ -15,19 +15,156 @@ */ import { TextAreaAutoHeight } from '@/components/TextAreaAutoHeight/TextAreaAutoHeight'; -import { FormLabel } from '@carbon/react'; +import { + dispatchChangeEventOnFormInputs, + submitFormOnEnter, +} from '@/utils/formUtils'; +import { isNotNull } from '@/utils/helpers'; +import { FormLabel, IconButton } from '@carbon/react'; +import { Add, Close } from '@carbon/react/icons'; +import clsx from 'clsx'; +import { FormEvent, useCallback, useRef } from 'react'; +import { useController, useForm, useFormContext } from 'react-hook-form'; +import { v4 as uuid } from 'uuid'; +import { + AssistantFormValues, + StarterQuestion, +} from './AssistantBuilderProvider'; import classes from './StarterQuestionsTextArea.module.scss'; +interface FormValues { + input: string; +} + export function StarterQuestionsTextArea() { + const formRef = useRef(null); + const { register, watch, handleSubmit, reset } = useForm({ + mode: 'onChange', + defaultValues: { + input: '', + }, + }); + const { setValue } = useFormContext(); + const { + field: { value: questions, onChange }, + } = useController({ + name: 'starterQuestions', + }); + + const inputValue = watch('input'); + + const hasMaxQuestions = questions && questions.length >= MAX_QUESTIONS; + + const setQuestions = (questions?: StarterQuestion[]) => { + if (isNotNull(questions)) { + setValue('starterQuestions', questions); + onChange(questions); + } + }; + + const addQuestion = (question: string) => { + const newQuestion = { + id: uuid(), + question, + }; + + setQuestions(questions ? [...questions, newQuestion] : [newQuestion]); + }; + + const removeQuestion = (id: string) => { + setQuestions(questions?.filter((question) => question.id !== id)); + }; + + const updateQuestion = (id: string, value: string) => { + setQuestions( + questions?.map((question) => + question.id === id ? { ...question, question: value } : question, + ), + ); + }; + + const submitForm = (event: FormEvent) => { + event.preventDefault(); + + handleSubmit(({ input }) => { + if (hasMaxQuestions || !input.trim()) { + return; + } + + addQuestion(input); + resetForm(); + })(); + }; + + const resetForm = useCallback(() => { + { + const formElem = formRef.current; + + if (!formElem) { + return; + } + + reset(); + dispatchChangeEventOnFormInputs(formElem); + } + }, [reset]); + return ( -
+
Starter questions - -
+ {!hasMaxQuestions && ( +
+ + + + + +
+ )} + + {questions && ( +
    + {questions.map(({ id, question }) => ( +
  • + updateQuestion(id, event.target.value)} + /> + + removeQuestion(id)} + > + + +
  • + ))} +
+ )} + ); } + +const MAX_QUESTIONS = 3; +const MAX_QUESTION_LENGTH = 512; diff --git a/src/modules/assistants/types.ts b/src/modules/assistants/types.ts index b8c0b475..9392b143 100644 --- a/src/modules/assistants/types.ts +++ b/src/modules/assistants/types.ts @@ -16,11 +16,13 @@ import { AssistantResult } from '@/app/api/assistants/types'; import { - AssitantIconName, AssistantIconColor, + AssitantIconName, } from './icons/AssistantBaseIcon'; -export interface AssistantMetadata { +export const STARTER_QUESTION_KEY_PREFIX = 'starterQuestion_'; + +export interface AssistantMetadata extends StarterQuestionsMetadata { icon?: AssitantIconName; color?: AssistantIconColor; } @@ -28,3 +30,7 @@ export interface AssistantMetadata { export type Assistant = Omit & { metadata: AssistantMetadata; }; + +export interface StarterQuestionsMetadata { + [key: `${typeof STARTER_QUESTION_KEY_PREFIX}${string}`]: string; +} diff --git a/src/modules/assistants/utils.ts b/src/modules/assistants/utils.ts index 54c1b7bd..6d0a84ff 100644 --- a/src/modules/assistants/utils.ts +++ b/src/modules/assistants/utils.ts @@ -16,7 +16,13 @@ import { AssistantResult } from '@/app/api/assistants/types'; import { decodeMetadata } from '@/app/api/utils'; -import { Assistant, AssistantMetadata } from './types'; +import { StarterQuestion } from './builder/AssistantBuilderProvider'; +import { + Assistant, + AssistantMetadata, + STARTER_QUESTION_KEY_PREFIX, + StarterQuestionsMetadata, +} from './types'; export function getAssistantFromAssistantResult( data: AssistantResult, @@ -26,3 +32,29 @@ export function getAssistantFromAssistantResult( metadata: decodeMetadata(data?.metadata), }; } + +export function encodeStarterQuestionsMetadata( + questions: StarterQuestion[] = [], +): StarterQuestionsMetadata { + return questions.reduce((starterQuestions, { id, question }) => { + if (question !== '') { + starterQuestions[`${STARTER_QUESTION_KEY_PREFIX}${id}`] = question; + } + return starterQuestions; + }, {} as StarterQuestionsMetadata); +} + +export function decodeStarterQuestionsMetadata( + metadata: StarterQuestionsMetadata = {}, +): StarterQuestion[] { + return Object.entries(metadata).reduce((starterQuestions, [key, value]) => { + if (key.startsWith(STARTER_QUESTION_KEY_PREFIX)) { + starterQuestions.push({ + id: key.replace(STARTER_QUESTION_KEY_PREFIX, ''), + question: value, + }); + } + + return starterQuestions; + }, [] as StarterQuestion[]); +} diff --git a/src/modules/chat/EmptyChatView.tsx b/src/modules/chat/EmptyChatView.tsx index ba5a1bc3..1c9be0e6 100644 --- a/src/modules/chat/EmptyChatView.tsx +++ b/src/modules/chat/EmptyChatView.tsx @@ -71,7 +71,7 @@ export const EmptyChatView = memo(function EmptyChatView({
- +
diff --git a/src/modules/chat/layout/InputBar.tsx b/src/modules/chat/layout/InputBar.tsx index 283fba01..5a1256d6 100644 --- a/src/modules/chat/layout/InputBar.tsx +++ b/src/modules/chat/layout/InputBar.tsx @@ -21,33 +21,43 @@ import { TextAreaAutoHeight } from '@/components/TextAreaAutoHeight/TextAreaAuto import { useAppContext } from '@/layout/providers/AppProvider'; import { AssistantBaseIcon } from '@/modules/assistants/icons/AssistantBaseIcon'; import { lastAssistantsQuery } from '@/modules/assistants/library/queries'; +import { + dispatchChangeEventOnFormInputs, + submitFormOnEnter, +} from '@/utils/formUtils'; +import { FeatureName, isFeatureEnabled } from '@/utils/isFeatureEnabled'; import { Button } from '@carbon/react'; import { Send, StopOutlineFilled, WarningFilled } from '@carbon/react/icons'; import { useQueryClient } from '@tanstack/react-query'; import clsx from 'clsx'; -import { memo, useCallback, useRef } from 'react'; +import { memo, useCallback, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; +import { mergeRefs } from 'react-merge-refs'; import { Attachment } from '../attachments/Attachment'; import { AttachmentsList } from '../attachments/AttachmentsList'; import { SendMessageResult, useChat } from '../providers/ChatProvider'; import { useFilesUpload } from '../providers/FilesUploadProvider'; import { FilesMenu } from './FilesMenu'; import classes from './InputBar.module.scss'; +import { PromptSuggestions } from './PromptSuggestions'; import { ThreadSettings } from './ThreadSettings'; -import { FeatureName, isFeatureEnabled } from '@/utils/isFeatureEnabled'; interface Props { + showSuggestions?: boolean; onMessageSubmit?: () => void; onMessageSent?: (result: SendMessageResult) => void; } export const InputBar = memo(function InputBar({ + showSuggestions, onMessageSubmit, onMessageSent, }: Props) { const queryClient = useQueryClient(); + const inputRef = useRef(null); const threadSettingsButtonRef = useRef(null); const formRef = useRef(null); + const [promptSuggestionsOpen, setPromptSuggestionsOpen] = useState(false); const { files, isPending: isFilesPending, @@ -57,7 +67,7 @@ export const InputBar = memo(function InputBar({ const { sendMessage, status, cancel, assistant, disabledTools } = useChat(); const { project } = useAppContext(); - const { register, watch, handleSubmit } = useForm({ + const { register, watch, handleSubmit, setValue } = useForm({ mode: 'onChange', }); @@ -68,17 +78,20 @@ export const InputBar = memo(function InputBar({ formElem.reset(); - // Manually trigger the 'change' event on each form element to correctly resize TextAreaAutoHeight - const inputs = formElem.querySelectorAll('input, textarea'); - - inputs.forEach((input) => { - const event = new Event('change', { bubbles: true }); - - input.dispatchEvent(event); - }); + dispatchChangeEventOnFormInputs(formElem); } }, []); + const submitFormWithInput = (value: string) => { + const formElem = formRef.current; + + if (!formElem) return; + + setValue('input', value); + + formElem.requestSubmit(); + }; + const isPending = status !== 'ready'; const toolsInUse = assistant.data?.tools?.length !== disabledTools.length; @@ -87,6 +100,10 @@ export const InputBar = memo(function InputBar({ const isFileUploadEnabled = isFeatureEnabled(FeatureName.Files); + const { ref: inputFormRef, ...inputFormProps } = register('input', { + required: true, + }); + return (
{ - // Submit form on enter, shit+enter for a new line (default behaviour) - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - e.currentTarget.closest('form')?.requestSubmit(); - } - }} + ref={mergeRefs([inputFormRef, inputRef])} + {...inputFormProps} + onKeyDown={submitFormOnEnter} />
{!isPending ? ( @@ -192,6 +204,15 @@ export const InputBar = memo(function InputBar({ /> )}
+ + {showSuggestions && ( + + )} diff --git a/src/modules/chat/layout/PromptSuggestions.module.scss b/src/modules/chat/layout/PromptSuggestions.module.scss new file mode 100644 index 00000000..78ee836d --- /dev/null +++ b/src/modules/chat/layout/PromptSuggestions.module.scss @@ -0,0 +1,87 @@ +/** + * Copyright 2024 IBM Corp. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@use 'styles/common' as *; + +.ref { + position: absolute; + inset: 0; + pointer-events: none; +} + +.root { + z-index: z('modal') - 1; + padding: 0 !important; +} + +.content { + background-color: $layer; + border-radius: $spacing-03; + box-shadow: $box-shadow; + padding: $spacing-06; + display: flex; + flex-direction: column; + row-gap: $spacing-05; +} + +.heading { + @include type-style(label-02); + line-height: (22 / 14); + color: $text-secondary; +} + +.list { + display: flex; + flex-direction: column; + row-gap: rem(9px); +} + +.item { + position: relative; + + .item { + &::before { + content: ''; + position: absolute; + block-size: 1px; + background-color: $border-subtle-01; + inset-inline: $spacing-03; + inset-block-start: rem(-5px); + } + } +} + +.button { + @include type-style(label-02); + background-color: transparent; + border: 0; + padding: $spacing-03; + text-align: start; + color: $text-primary; + transition: background-color $duration-fast-02; + border-radius: $spacing-03; + inline-size: 100%; + cursor: pointer; + display: flex; + &:hover { + background-color: $border-subtle-01; + } + &:focus-visible { + @include focus-outline('outline'); + } + > span { + @include truncateMultiline(2); + } +} diff --git a/src/modules/chat/layout/PromptSuggestions.tsx b/src/modules/chat/layout/PromptSuggestions.tsx new file mode 100644 index 00000000..eae2db5b --- /dev/null +++ b/src/modules/chat/layout/PromptSuggestions.tsx @@ -0,0 +1,251 @@ +/** + * Copyright 2024 IBM Corp. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Container } from '@/components/Container/Container'; +import { Tooltip } from '@/components/Tooltip/Tooltip'; +import { decodeStarterQuestionsMetadata } from '@/modules/assistants/utils'; +import { fadeProps } from '@/utils/fadeProps'; +import { + autoUpdate, + flip, + FloatingPortal, + offset, + useClick, + useDismiss, + useFloating, + useInteractions, + useRole, +} from '@floating-ui/react'; +import { AnimatePresence, motion } from 'framer-motion'; +import debounce from 'lodash/debounce'; +import { + Dispatch, + RefObject, + SetStateAction, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useChat } from '../providers/ChatProvider'; +import classes from './PromptSuggestions.module.scss'; + +interface Props { + inputRef: RefObject; + isOpen: boolean; + setIsOpen: Dispatch>; + onSubmit: (input: string) => void; +} + +export function PromptSuggestions({ + inputRef, + isOpen, + setIsOpen, + onSubmit, +}: Props) { + const { assistant } = useChat(); + + const suggestions = decodeStarterQuestionsMetadata(assistant.data?.metadata); + + const { refs, floatingStyles, context, placement } = useFloating({ + placement: 'bottom-start', + open: isOpen, + onOpenChange: setIsOpen, + whileElementsMounted: autoUpdate, + middleware: [offset(OFFSET), flip()], + }); + + const click = useClick(context); + const dismiss = useDismiss(context, { + outsidePress: (event) => event.target !== inputRef.current, + }); + const role = useRole(context, { role: 'dialog' }); + + const { getReferenceProps, getFloatingProps } = useInteractions([ + click, + dismiss, + role, + ]); + + const handleInputClick = (event: MouseEvent) => { + if (isOpen) { + return; + } + + const target = event.target as HTMLTextAreaElement; + + if (target.value === '') { + setIsOpen(true); + } + }; + + const handleInputKeyUp = (event: KeyboardEvent) => { + if (!isOpen) { + return; + } + + const target = event.target as HTMLTextAreaElement; + + if (target.value.length > 0) { + setIsOpen(false); + } + }; + + useEffect(() => { + const inputElement = inputRef.current; + + inputElement?.addEventListener('click', handleInputClick); + inputElement?.addEventListener('keyup', handleInputKeyUp); + + return () => { + inputElement?.removeEventListener('click', handleInputClick); + inputElement?.removeEventListener('keyup', handleInputKeyUp); + }; + }); + + if (suggestions.length === 0) { + return; + } + + return ( + <> +
+ + + {isOpen && ( + + + +
+

Suggestions

+ +
    + {suggestions.map(({ id, question }) => ( +
  • + +
  • + ))} +
+
+
+
+
+ )} +
+ + ); +} + +function SuggestionButton({ + content, + onSubmit, +}: { + content: string; + onSubmit: (input: string) => void; +}) { + const ref = useRef(null); + const [isTruncated, setIsTruncated] = useState(false); + + const checkOverflow = useCallback(() => { + const element = ref.current; + + if (!element) { + return; + } + + const { scrollHeight, clientHeight } = element; + + if (scrollHeight > clientHeight) { + setIsTruncated(true); + } else { + setIsTruncated(false); + } + }, []); + + const debouncedCheckOverflow = useMemo( + () => debounce(checkOverflow, 200), + [checkOverflow], + ); + + useEffect(() => { + const element = ref.current; + + if (!element) { + return; + } + + const resizeObserver = new ResizeObserver(() => { + debouncedCheckOverflow(); + }); + + resizeObserver.observe(element); + checkOverflow(); + + return () => { + if (element) { + resizeObserver.unobserve(element); + } + }; + }, [checkOverflow, debouncedCheckOverflow, isTruncated]); + + const buttonContent = ( + + ); + + return isTruncated ? ( + + {buttonContent} + + ) : ( + buttonContent + ); +} + +const OFFSET = { + mainAxis: 8, +}; diff --git a/src/utils/formUtils.ts b/src/utils/formUtils.ts new file mode 100644 index 00000000..ca93b396 --- /dev/null +++ b/src/utils/formUtils.ts @@ -0,0 +1,36 @@ +/** + * Copyright 2024 IBM Corp. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CODE_ENTER } from 'keycode-js'; +import { KeyboardEvent } from 'react'; + +export function submitFormOnEnter(event: KeyboardEvent) { + if (event.code === CODE_ENTER && !event.shiftKey) { + event.preventDefault(); + event.currentTarget.closest('form')?.requestSubmit(); + } +} + +// Manually trigger the 'change' event on each form element to correctly resize TextAreaAutoHeight +export function dispatchChangeEventOnFormInputs(form: HTMLFormElement) { + const inputs = form.querySelectorAll('input, textarea'); + + inputs.forEach((input) => { + const event = new Event('change', { bubbles: true }); + + input.dispatchEvent(event); + }); +}