瀏覽代碼

fix: multiple model configuration clear conversation by rerender (#2286)

zxhlyh 1 年之前
父節點
當前提交
68406b9906
共有 22 個文件被更改,包括 817 次插入713 次删除
  1. 1 0
      web/app/components/app/annotation/edit-annotation-modal/index.tsx
  2. 6 0
      web/app/components/app/configuration/config/agent/agent-tools/index.tsx
  3. 5 3
      web/app/components/app/configuration/config/index.tsx
  4. 4 3
      web/app/components/app/configuration/dataset-config/index.tsx
  5. 11 64
      web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx
  6. 3 0
      web/app/components/app/configuration/debug/debug-with-multiple-model/context.tsx
  7. 7 2
      web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx
  8. 70 43
      web/app/components/app/configuration/debug/debug-with-multiple-model/index.tsx
  9. 143 0
      web/app/components/app/configuration/debug/debug-with-single-model/index.tsx
  10. 103 0
      web/app/components/app/configuration/debug/hooks.tsx
  11. 46 420
      web/app/components/app/configuration/debug/index.tsx
  12. 1 0
      web/app/components/app/configuration/debug/types.ts
  13. 9 7
      web/app/components/app/configuration/index.tsx
  14. 14 11
      web/app/components/app/configuration/toolbox/annotation/annotation-ctrl-btn/index.tsx
  15. 5 2
      web/app/components/base/chat/chat/answer/agent-content.tsx
  16. 14 2
      web/app/components/base/chat/chat/answer/index.tsx
  17. 44 1
      web/app/components/base/chat/chat/answer/operation.tsx
  18. 19 16
      web/app/components/base/chat/chat/context.tsx
  19. 221 103
      web/app/components/base/chat/chat/hooks.ts
  20. 86 34
      web/app/components/base/chat/chat/index.tsx
  21. 4 1
      web/app/components/base/chat/types.ts
  22. 1 1
      web/context/debug-configuration.ts

+ 1 - 0
web/app/components/app/annotation/edit-annotation-modal/index.tsx

@@ -132,6 +132,7 @@ const EditAnnotationModal: FC<Props> = ({
         onRemove={() => {
           onRemove()
           setShowModal(false)
+          onHide()
         }}
         text={t('appDebug.feature.annotation.removeConfirm') as string}
       />

+ 6 - 0
web/app/components/app/configuration/config/agent/agent-tools/index.tsx

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
 import cn from 'classnames'
 import { useContext } from 'use-context-selector'
 import produce from 'immer'
+import { useFormattingChangedDispatcher } from '../../../debug/hooks'
 import ChooseTool from './choose-tool'
 import SettingBuiltInTool from './setting-built-in-tool'
 import Panel from '@/app/components/app/configuration/base/feature-panel'
@@ -27,6 +28,7 @@ const AgentTools: FC = () => {
   const { t } = useTranslation()
   const [isShowChooseTool, setIsShowChooseTool] = useState(false)
   const { modelConfig, setModelConfig, collectionList } = useContext(ConfigContext)
+  const formattingChangedDispatcher = useFormattingChangedDispatcher()
 
   const [currentTool, setCurrentTool] = useState<AgentToolWithMoreInfo>(null)
   const [selectedProviderId, setSelectedProviderId] = useState<string | undefined>(undefined)
@@ -49,6 +51,7 @@ const AgentTools: FC = () => {
     })
     setModelConfig(newModelConfig)
     setIsShowSettingTool(false)
+    formattingChangedDispatcher()
   }
 
   return (
@@ -141,6 +144,7 @@ const AgentTools: FC = () => {
                           draft.agentConfig.tools.splice(index, 1)
                         })
                         setModelConfig(newModelConfig)
+                        formattingChangedDispatcher()
                       }}>
                         <Trash03 className='w-4 h-4 text-gray-500' />
                       </div>
@@ -167,6 +171,7 @@ const AgentTools: FC = () => {
                           draft.agentConfig.tools.splice(index, 1)
                         })
                         setModelConfig(newModelConfig)
+                        formattingChangedDispatcher()
                       }}>
                         <Trash03 className='w-4 h-4 text-gray-500' />
                       </div>
@@ -183,6 +188,7 @@ const AgentTools: FC = () => {
                         (draft.agentConfig.tools[index] as any).enabled = enabled
                       })
                       setModelConfig(newModelConfig)
+                      formattingChangedDispatcher()
                     }} />
                 </div>
               </div>

+ 5 - 3
web/app/components/app/configuration/config/index.tsx

@@ -4,6 +4,7 @@ import React, { useRef } from 'react'
 import { useContext } from 'use-context-selector'
 import produce from 'immer'
 import { useBoolean, useScroll } from 'ahooks'
+import { useFormattingChangedDispatcher } from '../debug/hooks'
 import DatasetConfig from '../dataset-config'
 import ChatGroup from '../features/chat-group'
 import ExperienceEnchanceGroup from '../features/experience-enchance-group'
@@ -44,7 +45,6 @@ const Config: FC = () => {
     modelConfig,
     setModelConfig,
     setPrevPromptConfig,
-    setFormattingChanged,
     moreLikeThisConfig,
     setMoreLikeThisConfig,
     suggestedQuestionsAfterAnswerConfig,
@@ -64,6 +64,7 @@ const Config: FC = () => {
   const { data: speech2textDefaultModel } = useDefaultModel(4)
   const { data: text2speechDefaultModel } = useDefaultModel(5)
   const { setShowModerationSettingModal } = useModalContext()
+  const formattingChangedDispatcher = useFormattingChangedDispatcher()
 
   const promptTemplate = modelConfig.configs.prompt_template
   const promptVariables = modelConfig.configs.prompt_variables
@@ -73,9 +74,8 @@ const Config: FC = () => {
       draft.configs.prompt_template = newTemplate
       draft.configs.prompt_variables = [...draft.configs.prompt_variables, ...newVariables]
     })
-
     if (modelConfig.configs.prompt_template !== newTemplate)
-      setFormattingChanged(true)
+      formattingChangedDispatcher()
 
     setPrevPromptConfig(modelConfig.configs)
     setModelConfig(newModelConfig)
@@ -107,6 +107,7 @@ const Config: FC = () => {
       setSuggestedQuestionsAfterAnswerConfig(produce(suggestedQuestionsAfterAnswerConfig, (draft: SuggestedQuestionsAfterAnswerConfig) => {
         draft.enabled = value
       }))
+      formattingChangedDispatcher()
     },
     speechToText: speechToTextConfig.enabled,
     setSpeechToText: (value) => {
@@ -125,6 +126,7 @@ const Config: FC = () => {
       setCitationConfig(produce(citationConfig, (draft: CitationConfig) => {
         draft.enabled = value
       }))
+      formattingChangedDispatcher()
     },
     annotation: annotationConfig.enabled,
     setAnnotation: async (value) => {

+ 4 - 3
web/app/components/app/configuration/dataset-config/index.tsx

@@ -4,6 +4,7 @@ import React from 'react'
 import { useTranslation } from 'react-i18next'
 import { useContext } from 'use-context-selector'
 import produce from 'immer'
+import { useFormattingChangedDispatcher } from '../debug/hooks'
 import FeaturePanel from '../base/feature-panel'
 import OperationBtn from '../base/operation-btn'
 import CardItem from './card-item/item'
@@ -26,25 +27,25 @@ const DatasetConfig: FC = () => {
     mode,
     dataSets: dataSet,
     setDataSets: setDataSet,
-    setFormattingChanged,
     modelConfig,
     setModelConfig,
     showSelectDataSet,
     isAgent,
   } = useContext(ConfigContext)
+  const formattingChangedDispatcher = useFormattingChangedDispatcher()
 
   const hasData = dataSet.length > 0
 
   const onRemove = (id: string) => {
     setDataSet(dataSet.filter(item => item.id !== id))
-    setFormattingChanged(true)
+    formattingChangedDispatcher()
   }
 
   const handleSave = (newDataset: DataSet) => {
     const index = dataSet.findIndex(item => item.id === newDataset.id)
 
     setDataSet([...dataSet.slice(0, index), newDataset, ...dataSet.slice(index + 1)])
-    setFormattingChanged(true)
+    formattingChangedDispatcher()
   }
 
   const promptVariables = modelConfig.configs.prompt_variables

+ 11 - 64
web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx

@@ -1,6 +1,7 @@
 import type { FC } from 'react'
 import {
   memo,
+  useCallback,
   useMemo,
 } from 'react'
 import type { ModelAndParameter } from '../types'
@@ -9,16 +10,13 @@ import {
   APP_CHAT_WITH_MULTIPLE_MODEL_RESTART,
 } from '../types'
 import {
-  AgentStrategy,
-  ModelModeType,
-} from '@/types/app'
+  useConfigFromDebugContext,
+  useFormattingChangedSubscription,
+} from '../hooks'
 import Chat from '@/app/components/base/chat/chat'
 import { useChat } from '@/app/components/base/chat/chat/hooks'
 import { useDebugConfigurationContext } from '@/context/debug-configuration'
-import type {
-  ChatConfig,
-  OnSend,
-} from '@/app/components/base/chat/types'
+import type { OnSend } from '@/app/components/base/chat/types'
 import { useEventEmitterContextContext } from '@/context/event-emitter'
 import { useProviderContext } from '@/context/provider-context'
 import {
@@ -26,7 +24,6 @@ import {
   fetchSuggestedQuestions,
   stopChatMessageResponding,
 } from '@/service/debug'
-import { promptVariablesToUserInputsForm } from '@/utils/model-config'
 import Avatar from '@/app/components/base/avatar'
 import { useAppContext } from '@/context/app-context'
 import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
@@ -39,66 +36,14 @@ const ChatItem: FC<ChatItemProps> = ({
 }) => {
   const { userProfile } = useAppContext()
   const {
-    isAdvancedMode,
     modelConfig,
     appId,
     inputs,
-    promptMode,
-    speechToTextConfig,
-    introduction,
-    suggestedQuestions: openingSuggestedQuestions,
-    suggestedQuestionsAfterAnswerConfig,
-    citationConfig,
-    moderationConfig,
-    chatPromptConfig,
-    completionPromptConfig,
-    dataSets,
-    datasetConfigs,
     visionConfig,
-    annotationConfig,
     collectionList,
-    textToSpeechConfig,
   } = useDebugConfigurationContext()
   const { textGenerationModelList } = useProviderContext()
-  const postDatasets = dataSets.map(({ id }) => ({
-    dataset: {
-      enabled: true,
-      id,
-    },
-  }))
-  const contextVar = modelConfig.configs.prompt_variables.find(item => item.is_context_var)?.key
-  const config: ChatConfig = {
-    pre_prompt: !isAdvancedMode ? modelConfig.configs.prompt_template : '',
-    prompt_type: promptMode,
-    chat_prompt_config: isAdvancedMode ? chatPromptConfig : {},
-    completion_prompt_config: isAdvancedMode ? completionPromptConfig : {},
-    user_input_form: promptVariablesToUserInputsForm(modelConfig.configs.prompt_variables),
-    dataset_query_variable: contextVar || '',
-    opening_statement: introduction,
-    more_like_this: {
-      enabled: false,
-    },
-    suggested_questions: openingSuggestedQuestions,
-    suggested_questions_after_answer: suggestedQuestionsAfterAnswerConfig,
-    text_to_speech: textToSpeechConfig,
-    speech_to_text: speechToTextConfig,
-    retriever_resource: citationConfig,
-    sensitive_word_avoidance: moderationConfig,
-    agent_mode: {
-      ...modelConfig.agentConfig,
-      strategy: (modelAndParameter.provider === 'openai' && modelConfig.mode === ModelModeType.chat) ? AgentStrategy.functionCall : AgentStrategy.react,
-    },
-    dataset_configs: {
-      ...datasetConfigs,
-      datasets: {
-        datasets: [...postDatasets],
-      } as any,
-    },
-    file_upload: {
-      image: visionConfig,
-    },
-    annotation_reply: annotationConfig,
-  }
+  const config = useConfigFromDebugContext()
   const {
     chatList,
     isResponsing,
@@ -114,8 +59,9 @@ const ChatItem: FC<ChatItemProps> = ({
     [],
     taskId => stopChatMessageResponding(appId, taskId),
   )
+  useFormattingChangedSubscription(chatList)
 
-  const doSend: OnSend = (message, files) => {
+  const doSend: OnSend = useCallback((message, files) => {
     const currentProvider = textGenerationModelList.find(item => item.provider === modelAndParameter.provider)
     const currentModel = currentProvider?.models.find(model => model.model === modelAndParameter.model)
     const supportVision = currentModel?.features?.includes(ModelFeatureEnum.vision)
@@ -147,7 +93,7 @@ const ChatItem: FC<ChatItemProps> = ({
         onGetSuggestedQuestions: (responseItemId, getAbortController) => fetchSuggestedQuestions(appId, responseItemId, getAbortController),
       },
     )
-  }
+  }, [appId, config, handleSend, inputs, modelAndParameter, textGenerationModelList, visionConfig.enabled])
 
   const { eventEmitter } = useEventEmitterContextContext()
   eventEmitter?.useSubscription((v: any) => {
@@ -174,8 +120,9 @@ const ChatItem: FC<ChatItemProps> = ({
       chatList={chatList}
       isResponsing={isResponsing}
       noChatInput
+      noStopResponding
       chatContainerclassName='p-4'
-      chatFooterClassName='!-bottom-4'
+      chatFooterClassName='p-4 pb-0'
       suggestedQuestions={suggestedQuestions}
       onSend={doSend}
       showPromptLog

+ 3 - 0
web/app/components/app/configuration/debug/debug-with-multiple-model/context.tsx

@@ -7,6 +7,7 @@ export type DebugWithMultipleModelContextType = {
   multipleModelConfigs: ModelAndParameter[]
   onMultipleModelConfigsChange: (multiple: boolean, modelConfigs: ModelAndParameter[]) => void
   onDebugWithMultipleModelChange: (singleModelConfig: ModelAndParameter) => void
+  checkCanSend?: () => boolean
 }
 const DebugWithMultipleModelContext = createContext<DebugWithMultipleModelContextType>({
   multipleModelConfigs: [],
@@ -24,12 +25,14 @@ export const DebugWithMultipleModelContextProvider = ({
   onMultipleModelConfigsChange,
   multipleModelConfigs,
   onDebugWithMultipleModelChange,
+  checkCanSend,
 }: DebugWithMultipleModelContextProviderProps) => {
   return (
     <DebugWithMultipleModelContext.Provider value={{
       onMultipleModelConfigsChange,
       multipleModelConfigs,
       onDebugWithMultipleModelChange,
+      checkCanSend,
     }}>
       {children}
     </DebugWithMultipleModelContext.Provider>

+ 7 - 2
web/app/components/app/configuration/debug/debug-with-multiple-model/debug-item.tsx

@@ -1,4 +1,4 @@
-import type { FC } from 'react'
+import type { CSSProperties, FC } from 'react'
 import { useTranslation } from 'react-i18next'
 import { memo } from 'react'
 import type { ModelAndParameter } from '../types'
@@ -15,10 +15,12 @@ import { ModelStatusEnum } from '@/app/components/header/account-setting/model-p
 type DebugItemProps = {
   modelAndParameter: ModelAndParameter
   className?: string
+  style?: CSSProperties
 }
 const DebugItem: FC<DebugItemProps> = ({
   modelAndParameter,
   className,
+  style,
 }) => {
   const { t } = useTranslation()
   const { mode } = useDebugConfigurationContext()
@@ -61,7 +63,10 @@ const DebugItem: FC<DebugItemProps> = ({
   }
 
   return (
-    <div className={`flex flex-col min-w-[320px] rounded-xl bg-white border-[0.5px] border-black/5 ${className}`}>
+    <div
+      className={`flex flex-col min-w-[320px] rounded-xl bg-white border-[0.5px] border-black/5 ${className}`}
+      style={style}
+    >
       <div className='shrink-0 flex items-center justify-between h-10 px-3 border-b-[0.5px] border-b-black/5'>
         <div className='flex items-center justify-center w-6 h-5 font-medium italic text-gray-500'>
           #{index + 1}

+ 70 - 43
web/app/components/app/configuration/debug/debug-with-multiple-model/index.tsx

@@ -2,6 +2,7 @@ import type { FC } from 'react'
 import {
   memo,
   useCallback,
+  useMemo,
 } from 'react'
 import { APP_CHAT_WITH_MULTIPLE_MODEL } from '../types'
 import DebugItem from './debug-item'
@@ -21,10 +22,16 @@ const DebugWithMultipleModel = () => {
     speechToTextConfig,
     visionConfig,
   } = useDebugConfigurationContext()
-  const { multipleModelConfigs } = useDebugWithMultipleModelContext()
+  const {
+    multipleModelConfigs,
+    checkCanSend,
+  } = useDebugWithMultipleModelContext()
   const { eventEmitter } = useEventEmitterContextContext()
 
   const handleSend = useCallback((message: string, files?: VisionFile[]) => {
+    if (checkCanSend && !checkCanSend())
+      return
+
     eventEmitter?.emit({
       type: APP_CHAT_WITH_MULTIPLE_MODEL,
       payload: {
@@ -32,72 +39,90 @@ const DebugWithMultipleModel = () => {
         files,
       },
     } as any)
-  }, [eventEmitter])
+  }, [eventEmitter, checkCanSend])
 
   const twoLine = multipleModelConfigs.length === 2
   const threeLine = multipleModelConfigs.length === 3
   const fourLine = multipleModelConfigs.length === 4
 
+  const size = useMemo(() => {
+    let width = ''
+    let height = ''
+    if (twoLine) {
+      width = 'calc(50% - 4px - 24px)'
+      height = '100%'
+    }
+    if (threeLine) {
+      width = 'calc(33.3% - 5.33px - 16px)'
+      height = '100%'
+    }
+    if (fourLine) {
+      width = 'calc(50% - 4px - 24px)'
+      height = 'calc(50% - 4px)'
+    }
+
+    return {
+      width,
+      height,
+    }
+  }, [twoLine, threeLine, fourLine])
+  const position = useCallback((idx: number) => {
+    let translateX = '0'
+    let translateY = '0'
+
+    if (twoLine && idx === 1)
+      translateX = 'calc(100% + 8px)'
+    if (threeLine && idx === 1)
+      translateX = 'calc(100% + 8px)'
+    if (threeLine && idx === 2)
+      translateX = 'calc(200% + 16px)'
+    if (fourLine && idx === 1)
+      translateX = 'calc(100% + 8px)'
+    if (fourLine && idx === 2)
+      translateY = 'calc(100% + 8px)'
+    if (fourLine && idx === 3) {
+      translateX = 'calc(100% + 8px)'
+      translateY = 'calc(100% + 8px)'
+    }
+
+    return {
+      translateX,
+      translateY,
+    }
+  }, [twoLine, threeLine, fourLine])
+
   return (
     <div className='flex flex-col h-full'>
       <div
         className={`
-          mb-3 overflow-auto
-          ${(twoLine || threeLine) && 'flex gap-2'}
+          grow mb-3 relative px-6 overflow-auto
         `}
         style={{ height: mode === 'chat' ? 'calc(100% - 60px)' : '100%' }}
       >
         {
-          (twoLine || threeLine) && multipleModelConfigs.map(modelConfig => (
+          multipleModelConfigs.map((modelConfig, index) => (
             <DebugItem
               key={modelConfig.id}
               modelAndParameter={modelConfig}
               className={`
-                h-full min-h-[200px]
-                ${twoLine && 'w-1/2'}
-                ${threeLine && 'w-1/3'}
+                absolute left-6 top-0 min-h-[200px]
+                ${twoLine && index === 0 && 'mr-2'}
+                ${threeLine && (index === 0 || index === 1) && 'mr-2'}
+                ${fourLine && (index === 0 || index === 2) && 'mr-2'}
+                ${fourLine && (index === 0 || index === 1) && 'mb-2'}
               `}
+              style={{
+                width: size.width,
+                height: size.height,
+                transform: `translateX(${position(index).translateX}) translateY(${position(index).translateY})`,
+              }}
             />
           ))
         }
-        {
-          fourLine && (
-            <>
-              <div
-                className='flex space-x-2  mb-2 min-h-[200px]'
-                style={{ height: 'calc(50% - 4px)' }}
-              >
-                {
-                  multipleModelConfigs.slice(0, 2).map(modelConfig => (
-                    <DebugItem
-                      key={modelConfig.id}
-                      modelAndParameter={modelConfig}
-                      className='w-1/2 h-full'
-                    />
-                  ))
-                }
-              </div>
-              <div
-                className='flex space-x-2 min-h-[200px]'
-                style={{ height: 'calc(50% - 4px)' }}
-              >
-                {
-                  multipleModelConfigs.slice(2, 4).map(modelConfig => (
-                    <DebugItem
-                      key={modelConfig.id}
-                      modelAndParameter={modelConfig}
-                      className='w-1/2 h-full'
-                    />
-                  ))
-                }
-              </div>
-            </>
-          )
-        }
       </div>
       {
         mode === 'chat' && (
-          <div className='shrink-0'>
+          <div className='shrink-0 pb-4 px-6'>
             <ChatInput
               onSend={handleSend}
               speechToTextConfig={speechToTextConfig}
@@ -116,12 +141,14 @@ const DebugWithMultipleModelWrapper: FC<DebugWithMultipleModelContextType> = ({
   onMultipleModelConfigsChange,
   multipleModelConfigs,
   onDebugWithMultipleModelChange,
+  checkCanSend,
 }) => {
   return (
     <DebugWithMultipleModelContextProvider
       onMultipleModelConfigsChange={onMultipleModelConfigsChange}
       multipleModelConfigs={multipleModelConfigs}
       onDebugWithMultipleModelChange={onDebugWithMultipleModelChange}
+      checkCanSend={checkCanSend}
     >
       <DebugWithMultipleModelMemoed />
     </DebugWithMultipleModelContextProvider>

+ 143 - 0
web/app/components/app/configuration/debug/debug-with-single-model/index.tsx

@@ -0,0 +1,143 @@
+import {
+  forwardRef,
+  memo,
+  useCallback,
+  useImperativeHandle,
+  useMemo,
+} from 'react'
+import {
+  useConfigFromDebugContext,
+  useFormattingChangedSubscription,
+} from '../hooks'
+import Chat from '@/app/components/base/chat/chat'
+import { useChat } from '@/app/components/base/chat/chat/hooks'
+import { useDebugConfigurationContext } from '@/context/debug-configuration'
+import type { OnSend } from '@/app/components/base/chat/types'
+import { useProviderContext } from '@/context/provider-context'
+import {
+  fetchConvesationMessages,
+  fetchSuggestedQuestions,
+  stopChatMessageResponding,
+} from '@/service/debug'
+import Avatar from '@/app/components/base/avatar'
+import { useAppContext } from '@/context/app-context'
+import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
+
+type DebugWithSingleModelProps = {
+  checkCanSend?: () => boolean
+}
+export type DebugWithSingleModelRefType = {
+  handleRestart: () => void
+}
+const DebugWithSingleModel = forwardRef<DebugWithSingleModelRefType, DebugWithSingleModelProps>(({
+  checkCanSend,
+}, ref) => {
+  const { userProfile } = useAppContext()
+  const {
+    modelConfig,
+    appId,
+    inputs,
+    visionConfig,
+    collectionList,
+    completionParams,
+  } = useDebugConfigurationContext()
+  const { textGenerationModelList } = useProviderContext()
+  const config = useConfigFromDebugContext()
+  const {
+    chatList,
+    isResponsing,
+    handleSend,
+    suggestedQuestions,
+    handleStop,
+    handleRestart,
+    handleAnnotationAdded,
+    handleAnnotationEdited,
+    handleAnnotationRemoved,
+  } = useChat(
+    {
+      ...config,
+      supportAnnotation: true,
+      appId,
+    },
+    {
+      inputs,
+      promptVariables: modelConfig.configs.prompt_variables,
+    },
+    [],
+    taskId => stopChatMessageResponding(appId, taskId),
+  )
+  useFormattingChangedSubscription(chatList)
+
+  const doSend: OnSend = useCallback((message, files) => {
+    if (checkCanSend && !checkCanSend())
+      return
+    const currentProvider = textGenerationModelList.find(item => item.provider === modelConfig.provider)
+    const currentModel = currentProvider?.models.find(model => model.model === modelConfig.model_id)
+    const supportVision = currentModel?.features?.includes(ModelFeatureEnum.vision)
+
+    const configData = {
+      ...config,
+      model: {
+        provider: modelConfig.provider,
+        name: modelConfig.model_id,
+        mode: modelConfig.mode,
+        completion_params: completionParams,
+      },
+    }
+
+    const data: any = {
+      query: message,
+      inputs,
+      model_config: configData,
+    }
+
+    if (visionConfig.enabled && files?.length && supportVision)
+      data.files = files
+
+    handleSend(
+      `apps/${appId}/chat-messages`,
+      data,
+      {
+        onGetConvesationMessages: (conversationId, getAbortController) => fetchConvesationMessages(appId, conversationId, getAbortController),
+        onGetSuggestedQuestions: (responseItemId, getAbortController) => fetchSuggestedQuestions(appId, responseItemId, getAbortController),
+      },
+    )
+  }, [appId, checkCanSend, completionParams, config, handleSend, inputs, modelConfig, textGenerationModelList, visionConfig.enabled])
+
+  const allToolIcons = useMemo(() => {
+    const icons: Record<string, any> = {}
+    modelConfig.agentConfig.tools?.forEach((item: any) => {
+      icons[item.tool_name] = collectionList.find((collection: any) => collection.id === item.provider_id)?.icon
+    })
+    return icons
+  }, [collectionList, modelConfig.agentConfig.tools])
+
+  useImperativeHandle(ref, () => {
+    return {
+      handleRestart,
+    }
+  }, [handleRestart])
+
+  return (
+    <Chat
+      config={config}
+      chatList={chatList}
+      isResponsing={isResponsing}
+      chatContainerclassName='p-6'
+      chatFooterClassName='px-6 pt-10 pb-4'
+      suggestedQuestions={suggestedQuestions}
+      onSend={doSend}
+      onStopResponding={handleStop}
+      showPromptLog
+      questionIcon={<Avatar name={userProfile.name} size={40} />}
+      allToolIcons={allToolIcons}
+      onAnnotationEdited={handleAnnotationEdited}
+      onAnnotationAdded={handleAnnotationAdded}
+      onAnnotationRemoved={handleAnnotationRemoved}
+    />
+  )
+})
+
+DebugWithSingleModel.displayName = 'DebugWithSingleModel'
+
+export default memo(DebugWithSingleModel)

+ 103 - 0
web/app/components/app/configuration/debug/hooks.tsx

@@ -7,6 +7,17 @@ import type {
   DebugWithSingleOrMultipleModelConfigs,
   ModelAndParameter,
 } from './types'
+import { ORCHESTRATE_CHANGED } from './types'
+import type {
+  ChatConfig,
+  ChatItem,
+} from '@/app/components/base/chat/types'
+import {
+  AgentStrategy,
+} from '@/types/app'
+import { promptVariablesToUserInputsForm } from '@/utils/model-config'
+import { useDebugConfigurationContext } from '@/context/debug-configuration'
+import { useEventEmitterContextContext } from '@/context/event-emitter'
 
 export const useDebugWithSingleOrMultipleModel = (appId: string) => {
   const localeDebugWithSingleOrMultipleModelConfigs = localStorage.getItem('app-debug-with-single-or-multiple-models')
@@ -52,3 +63,95 @@ export const useDebugWithSingleOrMultipleModel = (appId: string) => {
     handleMultipleModelConfigsChange,
   }
 }
+
+export const useConfigFromDebugContext = () => {
+  const {
+    isAdvancedMode,
+    modelConfig,
+    appId,
+    promptMode,
+    speechToTextConfig,
+    introduction,
+    suggestedQuestions: openingSuggestedQuestions,
+    suggestedQuestionsAfterAnswerConfig,
+    citationConfig,
+    moderationConfig,
+    chatPromptConfig,
+    completionPromptConfig,
+    dataSets,
+    datasetConfigs,
+    visionConfig,
+    annotationConfig,
+    textToSpeechConfig,
+    isFunctionCall,
+  } = useDebugConfigurationContext()
+  const postDatasets = dataSets.map(({ id }) => ({
+    dataset: {
+      enabled: true,
+      id,
+    },
+  }))
+  const contextVar = modelConfig.configs.prompt_variables.find(item => item.is_context_var)?.key
+  const config: ChatConfig = {
+    pre_prompt: !isAdvancedMode ? modelConfig.configs.prompt_template : '',
+    prompt_type: promptMode,
+    chat_prompt_config: isAdvancedMode ? chatPromptConfig : {},
+    completion_prompt_config: isAdvancedMode ? completionPromptConfig : {},
+    user_input_form: promptVariablesToUserInputsForm(modelConfig.configs.prompt_variables),
+    dataset_query_variable: contextVar || '',
+    opening_statement: introduction,
+    more_like_this: {
+      enabled: false,
+    },
+    suggested_questions: openingSuggestedQuestions,
+    suggested_questions_after_answer: suggestedQuestionsAfterAnswerConfig,
+    text_to_speech: textToSpeechConfig,
+    speech_to_text: speechToTextConfig,
+    retriever_resource: citationConfig,
+    sensitive_word_avoidance: moderationConfig,
+    agent_mode: {
+      ...modelConfig.agentConfig,
+      strategy: isFunctionCall ? AgentStrategy.functionCall : AgentStrategy.react,
+    },
+    dataset_configs: {
+      ...datasetConfigs,
+      datasets: {
+        datasets: [...postDatasets],
+      } as any,
+    },
+    file_upload: {
+      image: visionConfig,
+    },
+    annotation_reply: annotationConfig,
+
+    supportAnnotation: true,
+    appId,
+  }
+
+  return config
+}
+
+export const useFormattingChangedDispatcher = () => {
+  const { eventEmitter } = useEventEmitterContextContext()
+
+  const dispatcher = useCallback(() => {
+    eventEmitter?.emit({
+      type: ORCHESTRATE_CHANGED,
+    } as any)
+  }, [eventEmitter])
+
+  return dispatcher
+}
+export const useFormattingChangedSubscription = (chatList: ChatItem[]) => {
+  const {
+    formattingChanged,
+    setFormattingChanged,
+  } = useDebugConfigurationContext()
+  const { eventEmitter } = useEventEmitterContextContext()
+  eventEmitter?.useSubscription((v: any) => {
+    if (v.type === ORCHESTRATE_CHANGED) {
+      if (chatList.some(item => item.isAnswer) && !formattingChanged)
+        setFormattingChanged(true)
+    }
+  })
+}

+ 46 - 420
web/app/components/app/configuration/debug/index.tsx

@@ -2,29 +2,27 @@
 import type { FC } from 'react'
 import useSWR from 'swr'
 import { useTranslation } from 'react-i18next'
-import React, { useEffect, useRef, useState } from 'react'
-import cn from 'classnames'
-import produce, { setAutoFreeze } from 'immer'
-import { useBoolean, useGetState } from 'ahooks'
+import React, { useCallback, useEffect, useState } from 'react'
+import { setAutoFreeze } from 'immer'
+import { useBoolean } from 'ahooks'
 import { useContext } from 'use-context-selector'
-import dayjs from 'dayjs'
 import HasNotSetAPIKEY from '../base/warning-mask/has-not-set-api'
 import FormattingChanged from '../base/warning-mask/formatting-changed'
 import GroupName from '../base/group-name'
 import CannotQueryDataset from '../base/warning-mask/cannot-query-dataset'
 import DebugWithMultipleModel from './debug-with-multiple-model'
+import DebugWithSingleModel from './debug-with-single-model'
+import type { DebugWithSingleModelRefType } from './debug-with-single-model'
 import type { ModelAndParameter } from './types'
 import {
   APP_CHAT_WITH_MULTIPLE_MODEL,
   APP_CHAT_WITH_MULTIPLE_MODEL_RESTART,
 } from './types'
-import { AgentStrategy, AppType, ModelModeType, TransferMethod } from '@/types/app'
-import PromptValuePanel, { replaceStringWithValues } from '@/app/components/app/configuration/prompt-value-panel'
-import type { IChatItem } from '@/app/components/app/chat/type'
-import Chat from '@/app/components/app/chat'
+import { AppType, ModelModeType, TransferMethod } from '@/types/app'
+import PromptValuePanel from '@/app/components/app/configuration/prompt-value-panel'
 import ConfigContext from '@/context/debug-configuration'
 import { ToastContext } from '@/app/components/base/toast'
-import { fetchConvesationMessages, fetchSuggestedQuestions, sendChatMessage, sendCompletionMessage, stopChatMessageResponding } from '@/service/debug'
+import { sendCompletionMessage } from '@/service/debug'
 import Button from '@/app/components/base/button'
 import type { ModelConfig as BackendModelConfig, VisionFile } from '@/types/app'
 import { promptVariablesToUserInputsForm } from '@/utils/model-config'
@@ -32,7 +30,6 @@ import TextGeneration from '@/app/components/app/text-generate/item'
 import { IS_CE_EDITION } from '@/config'
 import type { Inputs } from '@/models/debug'
 import { fetchFileUploadConfig } from '@/service/common'
-import type { Annotation as AnnotationType } from '@/models/log'
 import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
 import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
 import type { ModelParameterModalProps } from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
@@ -63,8 +60,6 @@ const Debug: FC<IDebug> = ({
   const {
     appId,
     mode,
-    isFunctionCall,
-    collectionList,
     modelModeType,
     hasSetBlockStatus,
     isAdvancedMode,
@@ -72,7 +67,6 @@ const Debug: FC<IDebug> = ({
     chatPromptConfig,
     completionPromptConfig,
     introduction,
-    suggestedQuestions,
     suggestedQuestionsAfterAnswerConfig,
     speechToTextConfig,
     textToSpeechConfig,
@@ -81,79 +75,36 @@ const Debug: FC<IDebug> = ({
     moreLikeThisConfig,
     formattingChanged,
     setFormattingChanged,
-    conversationId,
-    setConversationId,
-    controlClearChatMessage,
     dataSets,
     modelConfig,
     completionParams,
     hasSetContextVar,
     datasetConfigs,
     visionConfig,
-    annotationConfig,
     setVisionConfig,
   } = useContext(ConfigContext)
   const { eventEmitter } = useEventEmitterContextContext()
-  const { data: speech2textDefaultModel } = useDefaultModel(4)
   const { data: text2speechDefaultModel } = useDefaultModel(5)
-  const [chatList, setChatList, getChatList] = useGetState<IChatItem[]>([])
-  const chatListDomRef = useRef<HTMLDivElement>(null)
   const { data: fileUploadConfigResponse } = useSWR({ url: '/files/upload' }, fetchFileUploadConfig)
-  // onData change thought (the produce obj). https://github.com/immerjs/immer/issues/576
   useEffect(() => {
     setAutoFreeze(false)
     return () => {
       setAutoFreeze(true)
     }
   }, [])
-  useEffect(() => {
-    // scroll to bottom
-    if (chatListDomRef.current)
-      chatListDomRef.current.scrollTop = chatListDomRef.current.scrollHeight
-  }, [chatList])
-
-  const getIntroduction = () => replaceStringWithValues(introduction, modelConfig.configs.prompt_variables, inputs)
-  useEffect(() => {
-    if (introduction && !chatList.some(item => !item.isAnswer)) {
-      setChatList([{
-        id: `${Date.now()}`,
-        content: getIntroduction(),
-        isAnswer: true,
-        isOpeningStatement: true,
-        suggestedQuestions,
-      }])
-    }
-  }, [introduction, suggestedQuestions, modelConfig.configs.prompt_variables, inputs])
 
   const [isResponsing, { setTrue: setResponsingTrue, setFalse: setResponsingFalse }] = useBoolean(false)
-  const [abortController, setAbortController] = useState<AbortController | null>(null)
   const [isShowFormattingChangeConfirm, setIsShowFormattingChangeConfirm] = useState(false)
   const [isShowCannotQueryDataset, setShowCannotQueryDataset] = useState(false)
-  const [isShowSuggestion, setIsShowSuggestion] = useState(false)
-  const [messageTaskId, setMessageTaskId] = useState('')
-  const [hasStopResponded, setHasStopResponded, getHasStopResponded] = useGetState(false)
 
   useEffect(() => {
-    if (formattingChanged && chatList.some(item => !item.isAnswer))
+    if (formattingChanged)
       setIsShowFormattingChangeConfirm(true)
-
-    setFormattingChanged(false)
   }, [formattingChanged])
 
+  const debugWithSingleModelRef = React.useRef<DebugWithSingleModelRefType | null>(null)
   const handleClearConversation = () => {
-    setConversationId(null)
-    abortController?.abort()
-    setResponsingFalse()
-    setChatList(introduction
-      ? [{
-        id: `${Date.now()}`,
-        content: getIntroduction(),
-        isAnswer: true,
-        isOpeningStatement: true,
-        suggestedQuestions,
-      }]
-      : [])
-    setIsShowSuggestion(false)
+    debugWithSingleModelRef.current?.handleRestart()
   }
   const clearConversation = async () => {
     if (debugWithMultipleModel) {
@@ -169,18 +120,21 @@ const Debug: FC<IDebug> = ({
   const handleConfirm = () => {
     clearConversation()
     setIsShowFormattingChangeConfirm(false)
+    setFormattingChanged(false)
   }
 
   const handleCancel = () => {
     setIsShowFormattingChangeConfirm(false)
+    setFormattingChanged(false)
   }
 
   const { notify } = useContext(ToastContext)
-  const logError = (message: string) => {
+  const logError = useCallback((message: string) => {
     notify({ type: 'error', message })
-  }
+  }, [notify])
+  const [completionFiles, setCompletionFiles] = useState<VisionFile[]>([])
 
-  const checkCanSend = () => {
+  const checkCanSend = useCallback(() => {
     if (isAdvancedMode && mode === AppType.chat) {
       if (modelModeType === ModelModeType.completion) {
         if (!hasSetBlockStatus.history) {
@@ -214,319 +168,28 @@ const Debug: FC<IDebug> = ({
       return false
     }
 
-    // eslint-disable-next-line @typescript-eslint/no-use-before-define
     if (completionFiles.find(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)) {
       notify({ type: 'info', message: t('appDebug.errorMessage.waitForImgUpload') })
       return false
     }
     return !hasEmptyInput
-  }
-
-  const doShowSuggestion = isShowSuggestion && !isResponsing
-  const [suggestQuestions, setSuggestQuestions] = useState<string[]>([])
-  const [userQuery, setUserQuery] = useState('')
-  const onSend = async (message: string, files?: VisionFile[]) => {
-    if (isResponsing) {
-      notify({ type: 'info', message: t('appDebug.errorMessage.waitForResponse') })
-      return false
-    }
-
-    if (files?.find(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)) {
-      notify({ type: 'info', message: t('appDebug.errorMessage.waitForImgUpload') })
-      return false
-    }
-
-    const postDatasets = dataSets.map(({ id }) => ({
-      dataset: {
-        enabled: true,
-        id,
-      },
-    }))
-    const contextVar = modelConfig.configs.prompt_variables.find(item => item.is_context_var)?.key
-    const updateCurrentQA = ({
-      responseItem,
-      questionId,
-      placeholderAnswerId,
-      questionItem,
-    }: {
-      responseItem: IChatItem
-      questionId: string
-      placeholderAnswerId: string
-      questionItem: IChatItem
-    }) => {
-      // closesure new list is outdated.
-      const newListWithAnswer = produce(
-        getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
-        (draft) => {
-          if (!draft.find(item => item.id === questionId))
-            draft.push({ ...questionItem })
-
-          draft.push({ ...responseItem })
-        })
-      setChatList(newListWithAnswer)
-    }
-    const postModelConfig: BackendModelConfig = {
-      text_to_speech: {
-        enabled: false,
-      },
-      pre_prompt: !isAdvancedMode ? modelConfig.configs.prompt_template : '',
-      prompt_type: promptMode,
-      chat_prompt_config: {},
-      completion_prompt_config: {},
-      user_input_form: promptVariablesToUserInputsForm(modelConfig.configs.prompt_variables),
-      dataset_query_variable: contextVar || '',
-      opening_statement: introduction,
-      more_like_this: {
-        enabled: false,
-      },
-      suggested_questions_after_answer: suggestedQuestionsAfterAnswerConfig,
-      speech_to_text: speechToTextConfig,
-      retriever_resource: citationConfig,
-      sensitive_word_avoidance: moderationConfig,
-      agent_mode: {
-        ...modelConfig.agentConfig,
-        strategy: isFunctionCall ? AgentStrategy.functionCall : AgentStrategy.react,
-      },
-      model: {
-        provider: modelConfig.provider,
-        name: modelConfig.model_id,
-        mode: modelConfig.mode,
-        completion_params: completionParams as any,
-      },
-      dataset_configs: {
-        ...datasetConfigs,
-        datasets: {
-          datasets: [...postDatasets],
-        } as any,
-      },
-      file_upload: {
-        image: visionConfig,
-      },
-      annotation_reply: annotationConfig,
-    }
-
-    if (isAdvancedMode) {
-      postModelConfig.chat_prompt_config = chatPromptConfig
-      postModelConfig.completion_prompt_config = completionPromptConfig
-    }
-
-    const data: Record<string, any> = {
-      conversation_id: conversationId,
-      inputs,
-      query: message,
-      model_config: postModelConfig,
-    }
-
-    if (visionConfig.enabled && files && files?.length > 0) {
-      data.files = files.map((item) => {
-        if (item.transfer_method === TransferMethod.local_file) {
-          return {
-            ...item,
-            url: '',
-          }
-        }
-        return item
-      })
-    }
-
-    // qustion
-    const questionId = `question-${Date.now()}`
-    const questionItem = {
-      id: questionId,
-      content: message,
-      isAnswer: false,
-      message_files: files,
-    }
-
-    const placeholderAnswerId = `answer-placeholder-${Date.now()}`
-    const placeholderAnswerItem = {
-      id: placeholderAnswerId,
-      content: '',
-      isAnswer: true,
-    }
-
-    const newList = [...getChatList(), questionItem, placeholderAnswerItem]
-    setChatList(newList)
-
-    let isAgentMode = false
-
-    // answer
-    const responseItem: IChatItem = {
-      id: `${Date.now()}`,
-      content: '',
-      agent_thoughts: [],
-      message_files: [],
-      isAnswer: true,
-    }
-    let hasSetResponseId = false
-
-    let _newConversationId: null | string = null
-
-    setHasStopResponded(false)
-    setResponsingTrue()
-    setIsShowSuggestion(false)
-    sendChatMessage(appId, data, {
-      getAbortController: (abortController) => {
-        setAbortController(abortController)
-      },
-      onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: any) => {
-        // console.log('onData', message)
-        if (!isAgentMode) {
-          responseItem.content = responseItem.content + message
-        }
-        else {
-          const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
-          if (lastThought)
-            lastThought.thought = lastThought.thought + message // need immer setAutoFreeze
-        }
-        if (messageId && !hasSetResponseId) {
-          responseItem.id = messageId
-          hasSetResponseId = true
-        }
-
-        if (isFirstMessage && newConversationId) {
-          setConversationId(newConversationId)
-          _newConversationId = newConversationId
-        }
-        setMessageTaskId(taskId)
-
-        updateCurrentQA({
-          responseItem,
-          questionId,
-          placeholderAnswerId,
-          questionItem,
-        })
-      },
-      async onCompleted(hasError?: boolean) {
-        setResponsingFalse()
-        if (hasError)
-          return
-
-        if (_newConversationId) {
-          const { data }: any = await fetchConvesationMessages(appId, _newConversationId as string)
-          const newResponseItem = data.find((item: any) => item.id === responseItem.id)
-          if (!newResponseItem)
-            return
-
-          setChatList(produce(getChatList(), (draft) => {
-            const index = draft.findIndex(item => item.id === responseItem.id)
-            if (index !== -1) {
-              const requestion = draft[index - 1]
-              draft[index - 1] = {
-                ...requestion,
-                log: newResponseItem.message,
-              }
-              draft[index] = {
-                ...draft[index],
-                more: {
-                  time: dayjs.unix(newResponseItem.created_at).format('hh:mm A'),
-                  tokens: newResponseItem.answer_tokens + newResponseItem.message_tokens,
-                  latency: newResponseItem.provider_response_latency.toFixed(2),
-                },
-              }
-            }
-          }))
-        }
-        if (suggestedQuestionsAfterAnswerConfig.enabled && !getHasStopResponded()) {
-          const { data }: any = await fetchSuggestedQuestions(appId, responseItem.id)
-          setSuggestQuestions(data)
-          setIsShowSuggestion(true)
-        }
-      },
-      onFile(file) {
-        const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
-        if (lastThought)
-          responseItem.agent_thoughts![responseItem.agent_thoughts!.length - 1].message_files = [...(lastThought as any).message_files, file]
-
-        updateCurrentQA({
-          responseItem,
-          questionId,
-          placeholderAnswerId,
-          questionItem,
-        })
-      },
-      onThought(thought) {
-        isAgentMode = true
-        const response = responseItem as any
-        if (thought.message_id && !hasSetResponseId)
-          response.id = thought.message_id
-        if (response.agent_thoughts.length === 0) {
-          response.agent_thoughts.push(thought)
-        }
-        else {
-          const lastThought = response.agent_thoughts[response.agent_thoughts.length - 1]
-          // thought changed but still the same thought, so update.
-          if (lastThought.id === thought.id) {
-            thought.thought = lastThought.thought
-            thought.message_files = lastThought.message_files
-            responseItem.agent_thoughts![response.agent_thoughts.length - 1] = thought
-          }
-          else {
-            responseItem.agent_thoughts!.push(thought)
-          }
-        }
-        updateCurrentQA({
-          responseItem,
-          questionId,
-          placeholderAnswerId,
-          questionItem,
-        })
-      },
-      onMessageEnd: (messageEnd) => {
-        if (messageEnd.metadata?.annotation_reply) {
-          responseItem.id = messageEnd.id
-          responseItem.annotation = ({
-            id: messageEnd.metadata.annotation_reply.id,
-            authorName: messageEnd.metadata.annotation_reply.account.name,
-          } as AnnotationType)
-          const newListWithAnswer = produce(
-            getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
-            (draft) => {
-              if (!draft.find(item => item.id === questionId))
-                draft.push({ ...questionItem })
-
-              draft.push({
-                ...responseItem,
-              })
-            })
-          setChatList(newListWithAnswer)
-          return
-        }
-        responseItem.citation = messageEnd.metadata?.retriever_resources || []
-
-        const newListWithAnswer = produce(
-          getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
-          (draft) => {
-            if (!draft.find(item => item.id === questionId))
-              draft.push({ ...questionItem })
-
-            draft.push({ ...responseItem })
-          })
-        setChatList(newListWithAnswer)
-      },
-      onMessageReplace: (messageReplace) => {
-        responseItem.content = messageReplace.answer
-      },
-      onError() {
-        setResponsingFalse()
-        // role back placeholder answer
-        setChatList(produce(getChatList(), (draft) => {
-          draft.splice(draft.findIndex(item => item.id === placeholderAnswerId), 1)
-        }))
-      },
-    })
-    return true
-  }
-
-  useEffect(() => {
-    if (controlClearChatMessage)
-      setChatList([])
-  }, [controlClearChatMessage])
+  }, [
+    completionFiles,
+    hasSetBlockStatus.history,
+    hasSetBlockStatus.query,
+    inputs,
+    isAdvancedMode,
+    mode,
+    modelConfig.configs.prompt_variables,
+    t,
+    logError,
+    notify,
+    modelModeType,
+  ])
 
   const [completionRes, setCompletionRes] = useState('')
   const [messageId, setMessageId] = useState<string | null>(null)
 
-  const [completionFiles, setCompletionFiles] = useState<VisionFile[]>([])
   const sendTextCompletion = async () => {
     if (isResponsing) {
       notify({ type: 'info', message: t('appDebug.errorMessage.waitForResponse') })
@@ -685,13 +348,13 @@ const Debug: FC<IDebug> = ({
         setVisionConfig({
           ...visionConfig,
           enabled: true,
-        })
+        }, true)
       }
       else {
         setVisionConfig({
           ...visionConfig,
           enabled: false,
-        })
+        }, true)
       }
     }
   }
@@ -699,17 +362,10 @@ const Debug: FC<IDebug> = ({
   useEffect(() => {
     handleVisionConfigInMultipleModel()
   }, [multipleModelConfigs, mode])
-  const allToolIcons = (() => {
-    const icons: Record<string, any> = {}
-    modelConfig.agentConfig.tools?.forEach((item: any) => {
-      icons[item.tool_name] = collectionList.find((collection: any) => collection.id === item.provider_id)?.icon
-    })
-    return icons
-  })()
 
   return (
     <>
-      <div className="shrink-0">
+      <div className="shrink-0 pt-4 px-6">
         <div className='flex items-center justify-between mb-2'>
           <div className='h2 '>{t('appDebug.inputs.title')}</div>
           <div className='flex items-center'>
@@ -761,6 +417,7 @@ const Debug: FC<IDebug> = ({
               multipleModelConfigs={multipleModelConfigs}
               onMultipleModelConfigsChange={onMultipleModelConfigsChange}
               onDebugWithMultipleModelChange={handleChangeToSingleModel}
+              checkCanSend={checkCanSend}
             />
           </div>
         )
@@ -770,47 +427,16 @@ const Debug: FC<IDebug> = ({
           <div className="flex flex-col grow">
             {/* Chat */}
             {mode === AppType.chat && (
-              <div className="mt-[34px] h-full flex flex-col">
-                <div className={cn(doShowSuggestion ? 'pb-[140px]' : (isResponsing ? 'pb-[113px]' : 'pb-[76px]'), 'relative mt-1.5 grow h-[200px] overflow-hidden')}>
-                  <div className="h-full overflow-y-auto overflow-x-hidden" ref={chatListDomRef}>
-                    <Chat
-                      chatList={chatList}
-                      query={userQuery}
-                      onQueryChange={setUserQuery}
-                      onSend={onSend}
-                      checkCanSend={checkCanSend}
-                      feedbackDisabled
-                      useCurrentUserAvatar
-                      isResponsing={isResponsing}
-                      canStopResponsing={!!messageTaskId}
-                      abortResponsing={async () => {
-                        await stopChatMessageResponding(appId, messageTaskId)
-                        setHasStopResponded(true)
-                        setResponsingFalse()
-                      }}
-                      isShowSuggestion={doShowSuggestion}
-                      suggestionList={suggestQuestions}
-                      isShowSpeechToText={speechToTextConfig.enabled && !!speech2textDefaultModel}
-                      isShowTextToSpeech={textToSpeechConfig.enabled && !!text2speechDefaultModel}
-                      isShowCitation={citationConfig.enabled}
-                      isShowCitationHitInfo
-                      isShowPromptLog
-                      visionConfig={{
-                        ...visionConfig,
-                        image_file_size_limit: fileUploadConfigResponse?.image_file_size_limit,
-                      }}
-                      supportAnnotation
-                      appId={appId}
-                      onChatListChange={setChatList}
-                      allToolIcons={allToolIcons}
-                    />
-                  </div>
-                </div>
+              <div className='grow h-0 overflow-hidden'>
+                <DebugWithSingleModel
+                  ref={debugWithSingleModelRef}
+                  checkCanSend={checkCanSend}
+                />
               </div>
             )}
             {/* Text  Generation */}
             {mode === AppType.completion && (
-              <div className="mt-6">
+              <div className="mt-6 px-6 pb-4">
                 <GroupName name={t('appDebug.result')} />
                 {(completionRes || isResponsing) && (
                   <TextGeneration
@@ -830,12 +456,6 @@ const Debug: FC<IDebug> = ({
                 )}
               </div>
             )}
-            {isShowFormattingChangeConfirm && (
-              <FormattingChanged
-                onConfirm={handleConfirm}
-                onCancel={handleCancel}
-              />
-            )}
             {isShowCannotQueryDataset && (
               <CannotQueryDataset
                 onConfirm={() => setShowCannotQueryDataset(false)}
@@ -844,6 +464,12 @@ const Debug: FC<IDebug> = ({
           </div>
         )
       }
+      {isShowFormattingChangeConfirm && (
+        <FormattingChanged
+          onConfirm={handleConfirm}
+          onCancel={handleCancel}
+        />
+      )}
       {!hasSetAPIKEY && (<HasNotSetAPIKEY isTrailFinished={!IS_CE_EDITION} onSetting={onSetting} />)}
     </>
   )

+ 1 - 0
web/app/components/app/configuration/debug/types.ts

@@ -16,3 +16,4 @@ export type DebugWithSingleOrMultipleModelConfigs = {
 export const APP_CHAT_WITH_MULTIPLE_MODEL = 'APP_CHAT_WITH_MULTIPLE_MODEL'
 export const APP_CHAT_WITH_MULTIPLE_MODEL_RESTART = 'APP_CHAT_WITH_MULTIPLE_MODEL_RESTART'
 export const APP_SIDEBAR_SHOULD_COLLAPSE = 'APP_SIDEBAR_SHOULD_COLLAPSE'
+export const ORCHESTRATE_CHANGED = 'ORCHESTRATE_CHANGED'

+ 9 - 7
web/app/components/app/configuration/index.tsx

@@ -13,7 +13,10 @@ import Button from '../../base/button'
 import Loading from '../../base/loading'
 import useAdvancedPromptConfig from './hooks/use-advanced-prompt-config'
 import EditHistoryModal from './config-prompt/conversation-histroy/edit-modal'
-import { useDebugWithSingleOrMultipleModel } from './debug/hooks'
+import {
+  useDebugWithSingleOrMultipleModel,
+  useFormattingChangedDispatcher,
+} from './debug/hooks'
 import type { ModelAndParameter } from './debug/types'
 import { APP_SIDEBAR_SHOULD_COLLAPSE } from './debug/types'
 import PublishWithMultipleModel from './debug/debug-with-multiple-model/publish-with-multiple-model'
@@ -45,7 +48,6 @@ import { AgentStrategy, AppType, ModelModeType, RETRIEVE_TYPE, Resolution, Trans
 import { PromptMode } from '@/models/debug'
 import { ANNOTATION_DEFAULT, DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG, supportFunctionCallModels } from '@/config'
 import SelectDataSet from '@/app/components/app/configuration/dataset-config/select-dataset'
-import I18n from '@/context/i18n'
 import { useModalContext } from '@/context/modal-context'
 import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
 import Drawer from '@/app/components/base/drawer'
@@ -111,10 +113,11 @@ const Configuration: FC = () => {
       embedding_model_name: '',
     },
   })
+  const formattingChangedDispatcher = useFormattingChangedDispatcher()
   const setAnnotationConfig = (config: AnnotationReplyConfig, notSetFormatChanged?: boolean) => {
     doSetAnnotationConfig(config)
     if (!notSetFormatChanged)
-      setFormattingChanged(true)
+      formattingChangedDispatcher()
   }
 
   const [moderationConfig, setModerationConfig] = useState<ModerationConfig>({
@@ -203,7 +206,7 @@ const Configuration: FC = () => {
       return
     }
 
-    setFormattingChanged(true)
+    formattingChangedDispatcher()
     if (data.find(item => !item.name)) { // has not loaded selected dataset
       const newSelected = produce(data, (draft: any) => {
         data.forEach((item, index) => {
@@ -299,7 +302,7 @@ const Configuration: FC = () => {
       transfer_methods: config.transfer_methods || [TransferMethod.local_file],
     })
     if (!notNoticeFormattingChanged)
-      setFormattingChanged(true)
+      formattingChangedDispatcher()
   }
 
   const {
@@ -634,7 +637,6 @@ const Configuration: FC = () => {
   }
 
   const [showUseGPT4Confirm, setShowUseGPT4Confirm] = useState(false)
-  const { locale } = useContext(I18n)
 
   const { eventEmitter } = useEventEmitterContextContext()
   const {
@@ -820,7 +822,7 @@ const Configuration: FC = () => {
                     )
                 }
               </div>
-              <div className='flex flex-col grow h-0 px-6 py-4 rounded-tl-2xl border-t border-l bg-gray-50 '>
+              <div className='flex flex-col grow h-0 rounded-tl-2xl border-t border-l bg-gray-50 '>
                 <Debug
                   hasSetAPIKEY={hasSettedApiKey}
                   onSetting={() => setShowAccountSettingModal({ payload: 'provider' })}

+ 14 - 11
web/app/components/app/configuration/toolbox/annotation/annotation-ctrl-btn/index.tsx

@@ -97,18 +97,21 @@ const CacheCtrlBtn: FC<Props> = ({
               </div>
             </div>
           )
-          : (
-            <TooltipPlus
-              popupContent={t('appDebug.feature.annotation.add') as string}
-            >
-              <div
-                className='p-1 rounded-md hover:bg-[#EEF4FF] hover:text-[#444CE7] cursor-pointer'
-                onClick={handleAdd}
+          : answer
+            ? (
+              <TooltipPlus
+                popupContent={t('appDebug.feature.annotation.add') as string}
               >
-                <MessageFastPlus className='w-4 h-4' />
-              </div>
-            </TooltipPlus>
-          )}
+                <div
+                  className='p-1 rounded-md hover:bg-[#EEF4FF] hover:text-[#444CE7] cursor-pointer'
+                  onClick={handleAdd}
+                >
+                  <MessageFastPlus className='w-4 h-4' />
+                </div>
+              </TooltipPlus>
+            )
+            : null
+        }
         <TooltipPlus
           popupContent={t('appDebug.feature.annotation.edit') as string}
         >

+ 5 - 2
web/app/components/base/chat/chat/answer/agent-content.tsx

@@ -14,7 +14,10 @@ type AgentContentProps = {
 const AgentContent: FC<AgentContentProps> = ({
   item,
 }) => {
-  const { allToolIcons } = useChatContext()
+  const {
+    allToolIcons,
+    isResponsing,
+  } = useChatContext()
   const {
     annotation,
     agent_thoughts,
@@ -42,7 +45,7 @@ const AgentContent: FC<AgentContentProps> = ({
             <Thought
               thought={thought}
               allToolIcons={allToolIcons || {}}
-              isFinished={!!thought.observation}
+              isFinished={!!thought.observation || !isResponsing}
             />
           )}
 

+ 14 - 2
web/app/components/base/chat/chat/answer/index.tsx

@@ -15,9 +15,13 @@ import { EditTitle } from '@/app/components/app/annotation/edit-annotation-modal
 
 type AnswerProps = {
   item: ChatItem
+  question: string
+  index: number
 }
 const Answer: FC<AnswerProps> = ({
   item,
+  question,
+  index,
 }) => {
   const { t } = useTranslation()
   const {
@@ -56,7 +60,15 @@ const Answer: FC<AnswerProps> = ({
         <div className='relative pr-10'>
           <AnswerTriangle className='absolute -left-2 top-0 w-2 h-3 text-gray-100' />
           <div className='group relative inline-block px-4 py-3 max-w-full bg-gray-100 rounded-b-2xl rounded-tr-2xl text-sm text-gray-900'>
-            <Operation item={item} />
+            {
+              !responsing && (
+                <Operation
+                  item={item}
+                  question={question}
+                  index={index}
+                />
+              )
+            }
             {
               responsing && !content && !hasAgentThoughts && (
                 <div className='flex items-center justify-center w-6 h-5'>
@@ -75,7 +87,7 @@ const Answer: FC<AnswerProps> = ({
               )
             }
             {
-              annotation?.id && !annotation?.logAnnotation && (
+              annotation?.id && annotation.authorName && (
                 <EditTitle
                   className='mt-1'
                   title={t('appAnnotation.editBy', { author: annotation.authorName })}

+ 44 - 1
web/app/components/base/chat/chat/answer/operation.tsx

@@ -1,24 +1,39 @@
 import type { FC } from 'react'
+import { useState } from 'react'
 import type { ChatItem } from '../../types'
 import { useCurrentAnswerIsResponsing } from '../hooks'
 import { useChatContext } from '../context'
 import CopyBtn from '@/app/components/app/chat/copy-btn'
 import { MessageFast } from '@/app/components/base/icons/src/vender/solid/communication'
 import AudioBtn from '@/app/components/base/audio-btn'
+import AnnotationCtrlBtn from '@/app/components/app/configuration/toolbox/annotation/annotation-ctrl-btn'
+import EditReplyModal from '@/app/components/app/annotation/edit-annotation-modal'
 
 type OperationProps = {
   item: ChatItem
+  question: string
+  index: number
 }
 const Operation: FC<OperationProps> = ({
   item,
+  question,
+  index,
 }) => {
-  const { config } = useChatContext()
+  const {
+    config,
+    onAnnotationAdded,
+    onAnnotationEdited,
+    onAnnotationRemoved,
+  } = useChatContext()
+  const [isShowReplyModal, setIsShowReplyModal] = useState(false)
   const responsing = useCurrentAnswerIsResponsing(item.id)
   const {
+    id,
     isOpeningStatement,
     content,
     annotation,
   } = item
+  const hasAnnotation = !!annotation?.id
 
   return (
     <div className='absolute top-[-14px] right-[-14px] flex justify-end gap-1'>
@@ -36,6 +51,34 @@ const Operation: FC<OperationProps> = ({
           className='hidden group-hover:block'
         />
       )}
+      {(!isOpeningStatement && config?.supportAnnotation && config.annotation_reply?.enabled) && (
+        <AnnotationCtrlBtn
+          appId={config?.appId || ''}
+          messageId={id}
+          annotationId={annotation?.id || ''}
+          className='hidden group-hover:block ml-1 shrink-0'
+          cached={hasAnnotation}
+          query={question}
+          answer={content}
+          onAdded={(id, authorName) => onAnnotationAdded?.(id, authorName, question, content, index)}
+          onEdit={() => setIsShowReplyModal(true)}
+          onRemoved={() => onAnnotationRemoved?.(index)}
+        />
+      )}
+
+      <EditReplyModal
+        isShow={isShowReplyModal}
+        onHide={() => setIsShowReplyModal(false)}
+        query={question}
+        answer={content}
+        onEdited={(editedQuery, editedAnswer) => onAnnotationEdited?.(editedQuery, editedAnswer, index)}
+        onAdded={(annotationId, authorName, editedQuery, editedAnswer) => onAnnotationAdded?.(annotationId, authorName, editedQuery, editedAnswer, index)}
+        appId={config?.appId || ''}
+        messageId={id}
+        annotationId={annotation?.id || ''}
+        createdAt={annotation?.created_at}
+        onRemove={() => onAnnotationRemoved?.(index)}
+      />
       {
         annotation?.id && (
           <div

+ 19 - 16
web/app/components/base/chat/chat/context.tsx

@@ -2,23 +2,20 @@
 
 import type { ReactNode } from 'react'
 import { createContext, useContext } from 'use-context-selector'
-import type {
-  ChatConfig,
-  ChatItem,
-  OnSend,
-} from '../types'
-import type { Emoji } from '@/app/components/tools/types'
+import type { ChatProps } from './index'
 
-export type ChatContextValue = {
-  config?: ChatConfig
-  isResponsing?: boolean
-  chatList: ChatItem[]
-  showPromptLog?: boolean
-  questionIcon?: ReactNode
-  answerIcon?: ReactNode
-  allToolIcons?: Record<string, string | Emoji>
-  onSend?: OnSend
-}
+export type ChatContextValue = Pick<ChatProps, 'config'
+  | 'isResponsing'
+  | 'chatList'
+  | 'showPromptLog'
+  | 'questionIcon'
+  | 'answerIcon'
+  | 'allToolIcons'
+  | 'onSend'
+  | 'onAnnotationEdited'
+  | 'onAnnotationAdded'
+  | 'onAnnotationRemoved'
+>
 
 const ChatContext = createContext<ChatContextValue>({
   chatList: [],
@@ -38,6 +35,9 @@ export const ChatContextProvider = ({
   answerIcon,
   allToolIcons,
   onSend,
+  onAnnotationEdited,
+  onAnnotationAdded,
+  onAnnotationRemoved,
 }: ChatContextProviderProps) => {
   return (
     <ChatContext.Provider value={{
@@ -49,6 +49,9 @@ export const ChatContextProvider = ({
       answerIcon,
       allToolIcons,
       onSend,
+      onAnnotationEdited,
+      onAnnotationAdded,
+      onAnnotationRemoved,
     }}>
       {children}
     </ChatContext.Provider>

+ 221 - 103
web/app/components/base/chat/chat/hooks.ts

@@ -1,11 +1,11 @@
 import {
+  useCallback,
   useEffect,
   useRef,
   useState,
 } from 'react'
 import { useTranslation } from 'react-i18next'
 import { produce } from 'immer'
-import { useGetState } from 'ahooks'
 import dayjs from 'dayjs'
 import type {
   ChatConfig,
@@ -19,12 +19,53 @@ import { TransferMethod } from '@/types/app'
 import { useToastContext } from '@/app/components/base/toast'
 import { ssePost } from '@/service/base'
 import { replaceStringWithValues } from '@/app/components/app/configuration/prompt-value-panel'
+import type { Annotation } from '@/models/log'
 
 type GetAbortController = (abortController: AbortController) => void
 type SendCallback = {
   onGetConvesationMessages: (conversationId: string, getAbortController: GetAbortController) => Promise<any>
   onGetSuggestedQuestions?: (responseItemId: string, getAbortController: GetAbortController) => Promise<any>
 }
+
+export const useCheckPromptVariables = () => {
+  const { t } = useTranslation()
+  const { notify } = useToastContext()
+
+  const checkPromptVariables = useCallback((promptVariablesConfig: {
+    inputs: Inputs
+    promptVariables: PromptVariable[]
+  }) => {
+    const {
+      promptVariables,
+      inputs,
+    } = promptVariablesConfig
+    let hasEmptyInput = ''
+    const requiredVars = promptVariables.filter(({ key, name, required, type }) => {
+      if (type === 'api')
+        return false
+      const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
+      return res
+    })
+
+    if (requiredVars?.length) {
+      requiredVars.forEach(({ key, name }) => {
+        if (hasEmptyInput)
+          return
+
+        if (!inputs[key])
+          hasEmptyInput = name
+      })
+    }
+
+    if (hasEmptyInput) {
+      notify({ type: 'error', message: t('appDebug.errorMessage.valueOfVarRequired', { key: hasEmptyInput }) })
+      return false
+    }
+  }, [notify, t])
+
+  return checkPromptVariables
+}
+
 export const useChat = (
   config: ChatConfig,
   promptVariablesConfig?: {
@@ -39,19 +80,31 @@ export const useChat = (
   const connversationId = useRef('')
   const hasStopResponded = useRef(false)
   const [isResponsing, setIsResponsing] = useState(false)
-  const [chatList, setChatList, getChatList] = useGetState<ChatItem[]>(prevChatList || [])
-  const [taskId, setTaskId] = useState('')
+  const isResponsingRef = useRef(false)
+  const [chatList, setChatList] = useState<ChatItem[]>(prevChatList || [])
+  const chatListRef = useRef<ChatItem[]>(prevChatList || [])
+  const taskIdRef = useRef('')
   const [suggestedQuestions, setSuggestQuestions] = useState<string[]>([])
-  const [abortController, setAbortController] = useState<AbortController | null>(null)
-  const [conversationMessagesAbortController, setConversationMessagesAbortController] = useState<AbortController | null>(null)
-  const [suggestedQuestionsAbortController, setSuggestedQuestionsAbortController] = useState<AbortController | null>(null)
-
-  const getIntroduction = (str: string) => {
+  const abortControllerRef = useRef<AbortController | null>(null)
+  const conversationMessagesAbortControllerRef = useRef<AbortController | null>(null)
+  const suggestedQuestionsAbortControllerRef = useRef<AbortController | null>(null)
+  const checkPromptVariables = useCheckPromptVariables()
+
+  const handleUpdateChatList = useCallback((newChatList: ChatItem[]) => {
+    setChatList(newChatList)
+    chatListRef.current = newChatList
+  }, [])
+  const handleResponsing = useCallback((isResponsing: boolean) => {
+    setIsResponsing(isResponsing)
+    isResponsingRef.current = isResponsing
+  }, [])
+
+  const getIntroduction = useCallback((str: string) => {
     return replaceStringWithValues(str, promptVariablesConfig?.promptVariables || [], promptVariablesConfig?.inputs || {})
-  }
+  }, [promptVariablesConfig?.inputs, promptVariablesConfig?.promptVariables])
   useEffect(() => {
-    if (config.opening_statement && !chatList.some(item => !item.isAnswer)) {
-      setChatList([{
+    if (config.opening_statement && !chatList.length) {
+      handleUpdateChatList([{
         id: `${Date.now()}`,
         content: getIntroduction(config.opening_statement),
         isAnswer: true,
@@ -59,25 +112,31 @@ export const useChat = (
         suggestedQuestions: config.suggested_questions,
       }])
     }
-  }, [config.opening_statement, config.suggested_questions, promptVariablesConfig?.inputs])
-
-  const handleStop = () => {
-    if (stopChat && taskId)
-      stopChat(taskId)
-    if (abortController)
-      abortController.abort()
-    if (conversationMessagesAbortController)
-      conversationMessagesAbortController.abort()
-    if (suggestedQuestionsAbortController)
-      suggestedQuestionsAbortController.abort()
-  }
+  }, [
+    config.opening_statement,
+    config.suggested_questions,
+    getIntroduction,
+    chatList,
+    handleUpdateChatList,
+  ])
 
-  const handleRestart = () => {
-    handleStop()
+  const handleStop = useCallback(() => {
     hasStopResponded.current = true
+    handleResponsing(false)
+    if (stopChat && taskIdRef.current)
+      stopChat(taskIdRef.current)
+    if (abortControllerRef.current)
+      abortControllerRef.current.abort()
+    if (conversationMessagesAbortControllerRef.current)
+      conversationMessagesAbortControllerRef.current.abort()
+    if (suggestedQuestionsAbortControllerRef.current)
+      suggestedQuestionsAbortControllerRef.current.abort()
+  }, [stopChat, handleResponsing])
+
+  const handleRestart = useCallback(() => {
+    handleStop()
     connversationId.current = ''
-    setIsResponsing(false)
-    setChatList(config.opening_statement
+    const newChatList = config.opening_statement
       ? [{
         id: `${Date.now()}`,
         content: config.opening_statement,
@@ -85,10 +144,38 @@ export const useChat = (
         isOpeningStatement: true,
         suggestedQuestions: config.suggested_questions,
       }]
-      : [])
+      : []
+    handleUpdateChatList(newChatList)
     setSuggestQuestions([])
-  }
-  const handleSend = async (
+  }, [
+    config,
+    handleStop,
+    handleUpdateChatList,
+  ])
+
+  const updateCurrentQA = useCallback(({
+    responseItem,
+    questionId,
+    placeholderAnswerId,
+    questionItem,
+  }: {
+    responseItem: ChatItem
+    questionId: string
+    placeholderAnswerId: string
+    questionItem: ChatItem
+  }) => {
+    const newListWithAnswer = produce(
+      chatListRef.current.filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
+      (draft) => {
+        if (!draft.find(item => item.id === questionId))
+          draft.push({ ...questionItem })
+
+        draft.push({ ...responseItem })
+      })
+    handleUpdateChatList(newListWithAnswer)
+  }, [handleUpdateChatList])
+
+  const handleSend = useCallback(async (
     url: string,
     data: any,
     {
@@ -97,62 +184,13 @@ export const useChat = (
     }: SendCallback,
   ) => {
     setSuggestQuestions([])
-    if (isResponsing) {
+    if (isResponsingRef.current) {
       notify({ type: 'info', message: t('appDebug.errorMessage.waitForResponse') })
       return false
     }
 
-    if (promptVariablesConfig?.inputs && promptVariablesConfig?.promptVariables) {
-      const {
-        promptVariables,
-        inputs,
-      } = promptVariablesConfig
-      let hasEmptyInput = ''
-      const requiredVars = promptVariables.filter(({ key, name, required, type }) => {
-        if (type === 'api')
-          return false
-        const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
-        return res
-      })
-
-      if (requiredVars?.length) {
-        requiredVars.forEach(({ key, name }) => {
-          if (hasEmptyInput)
-            return
-
-          if (!inputs[key])
-            hasEmptyInput = name
-        })
-      }
-
-      if (hasEmptyInput) {
-        notify({ type: 'error', message: t('appDebug.errorMessage.valueOfVarRequired', { key: hasEmptyInput }) })
-        return false
-      }
-    }
-
-    const updateCurrentQA = ({
-      responseItem,
-      questionId,
-      placeholderAnswerId,
-      questionItem,
-    }: {
-      responseItem: ChatItem
-      questionId: string
-      placeholderAnswerId: string
-      questionItem: ChatItem
-    }) => {
-      // closesure new list is outdated.
-      const newListWithAnswer = produce(
-        getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
-        (draft) => {
-          if (!draft.find(item => item.id === questionId))
-            draft.push({ ...questionItem })
-
-          draft.push({ ...responseItem })
-        })
-      setChatList(newListWithAnswer)
-    }
+    if (promptVariablesConfig?.inputs && promptVariablesConfig?.promptVariables)
+      checkPromptVariables(promptVariablesConfig)
 
     const questionId = `question-${Date.now()}`
     const questionItem = {
@@ -169,8 +207,8 @@ export const useChat = (
       isAnswer: true,
     }
 
-    const newList = [...getChatList(), questionItem, placeholderAnswerItem]
-    setChatList(newList)
+    const newList = [...chatListRef.current, questionItem, placeholderAnswerItem]
+    handleUpdateChatList(newList)
 
     // answer
     const responseItem: ChatItem = {
@@ -181,7 +219,7 @@ export const useChat = (
       isAnswer: true,
     }
 
-    setIsResponsing(true)
+    handleResponsing(true)
     hasStopResponded.current = false
 
     const bodyParams = {
@@ -211,7 +249,7 @@ export const useChat = (
       },
       {
         getAbortController: (abortController) => {
-          setAbortController(abortController)
+          abortControllerRef.current = abortController
         },
         onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: any) => {
           if (!isAgentMode) {
@@ -231,7 +269,7 @@ export const useChat = (
           if (isFirstMessage && newConversationId)
             connversationId.current = newConversationId
 
-          setTaskId(taskId)
+          taskIdRef.current = taskId
           if (messageId)
             responseItem.id = messageId
 
@@ -243,21 +281,21 @@ export const useChat = (
           })
         },
         async onCompleted(hasError?: boolean) {
-          setIsResponsing(false)
+          handleResponsing(false)
 
           if (hasError)
             return
 
-          if (connversationId.current) {
+          if (connversationId.current && !hasStopResponded.current) {
             const { data }: any = await onGetConvesationMessages(
               connversationId.current,
-              newAbortController => setConversationMessagesAbortController(newAbortController),
+              newAbortController => conversationMessagesAbortControllerRef.current = newAbortController,
             )
             const newResponseItem = data.find((item: any) => item.id === responseItem.id)
             if (!newResponseItem)
               return
 
-            setChatList(produce(getChatList(), (draft) => {
+            const newChatList = produce(chatListRef.current, (draft) => {
               const index = draft.findIndex(item => item.id === responseItem.id)
               if (index !== -1) {
                 const requestion = draft[index - 1]
@@ -274,12 +312,13 @@ export const useChat = (
                   },
                 }
               }
-            }))
+            })
+            handleUpdateChatList(newChatList)
           }
           if (config.suggested_questions_after_answer?.enabled && !hasStopResponded.current && onGetSuggestedQuestions) {
             const { data }: any = await onGetSuggestedQuestions(
               responseItem.id,
-              newAbortController => setSuggestedQuestionsAbortController(newAbortController),
+              newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
             )
             setSuggestQuestions(data)
           }
@@ -330,8 +369,9 @@ export const useChat = (
               id: messageEnd.metadata.annotation_reply.id,
               authorName: messageEnd.metadata.annotation_reply.account.name,
             })
+            const baseState = chatListRef.current.filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId)
             const newListWithAnswer = produce(
-              getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
+              baseState,
               (draft) => {
                 if (!draft.find(item => item.id === questionId))
                   draft.push({ ...questionItem })
@@ -340,38 +380,113 @@ export const useChat = (
                   ...responseItem,
                 })
               })
-            setChatList(newListWithAnswer)
+            handleUpdateChatList(newListWithAnswer)
             return
           }
           responseItem.citation = messageEnd.metadata?.retriever_resources || []
 
           const newListWithAnswer = produce(
-            getChatList().filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
+            chatListRef.current.filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
             (draft) => {
               if (!draft.find(item => item.id === questionId))
                 draft.push({ ...questionItem })
 
               draft.push({ ...responseItem })
             })
-          setChatList(newListWithAnswer)
+          handleUpdateChatList(newListWithAnswer)
         },
         onMessageReplace: (messageReplace) => {
           responseItem.content = messageReplace.answer
         },
         onError() {
-          setIsResponsing(false)
-          // role back placeholder answer
-          setChatList(produce(getChatList(), (draft) => {
+          handleResponsing(false)
+          const newChatList = produce(chatListRef.current, (draft) => {
             draft.splice(draft.findIndex(item => item.id === placeholderAnswerId), 1)
-          }))
+          })
+          handleUpdateChatList(newChatList)
         },
       })
     return true
-  }
+  }, [
+    checkPromptVariables,
+    config.suggested_questions_after_answer,
+    updateCurrentQA,
+    t,
+    notify,
+    promptVariablesConfig,
+    handleUpdateChatList,
+    handleResponsing,
+  ])
+
+  const handleAnnotationEdited = useCallback((query: string, answer: string, index: number) => {
+    setChatList(chatListRef.current.map((item, i) => {
+      if (i === index - 1) {
+        return {
+          ...item,
+          content: query,
+        }
+      }
+      if (i === index) {
+        return {
+          ...item,
+          content: answer,
+          annotation: {
+            ...item.annotation,
+            logAnnotation: undefined,
+          } as any,
+        }
+      }
+      return item
+    }))
+  }, [])
+  const handleAnnotationAdded = useCallback((annotationId: string, authorName: string, query: string, answer: string, index: number) => {
+    setChatList(chatListRef.current.map((item, i) => {
+      if (i === index - 1) {
+        return {
+          ...item,
+          content: query,
+        }
+      }
+      if (i === index) {
+        const answerItem = {
+          ...item,
+          content: item.content,
+          annotation: {
+            id: annotationId,
+            authorName,
+            logAnnotation: {
+              content: answer,
+              account: {
+                id: '',
+                name: authorName,
+                email: '',
+              },
+            },
+          } as Annotation,
+        }
+        return answerItem
+      }
+      return item
+    }))
+  }, [])
+  const handleAnnotationRemoved = useCallback((index: number) => {
+    setChatList(chatListRef.current.map((item, i) => {
+      if (i === index) {
+        return {
+          ...item,
+          content: item.content,
+          annotation: {
+            ...(item.annotation || {}),
+            id: '',
+          } as Annotation,
+        }
+      }
+      return item
+    }))
+  }, [])
 
   return {
     chatList,
-    getChatList,
     setChatList,
     conversationId: connversationId.current,
     isResponsing,
@@ -380,6 +495,9 @@ export const useChat = (
     suggestedQuestions,
     handleRestart,
     handleStop,
+    handleAnnotationEdited,
+    handleAnnotationAdded,
+    handleAnnotationRemoved,
   }
 }
 

+ 86 - 34
web/app/components/base/chat/chat/index.tsx

@@ -4,8 +4,10 @@ import type {
 } from 'react'
 import {
   memo,
+  useEffect,
   useRef,
 } from 'react'
+import { useTranslation } from 'react-i18next'
 import { useThrottleEffect } from 'ahooks'
 import type {
   ChatConfig,
@@ -18,13 +20,17 @@ import ChatInput from './chat-input'
 import TryToAsk from './try-to-ask'
 import { ChatContextProvider } from './context'
 import type { Emoji } from '@/app/components/tools/types'
+import Button from '@/app/components/base/button'
+import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
 
 export type ChatProps = {
-  config: ChatConfig
-  onSend?: OnSend
   chatList: ChatItem[]
-  isResponsing: boolean
+  config?: ChatConfig
+  isResponsing?: boolean
+  noStopResponding?: boolean
+  onStopResponding?: () => void
   noChatInput?: boolean
+  onSend?: OnSend
   chatContainerclassName?: string
   chatFooterClassName?: string
   suggestedQuestions?: string[]
@@ -32,12 +38,17 @@ export type ChatProps = {
   questionIcon?: ReactNode
   answerIcon?: ReactNode
   allToolIcons?: Record<string, string | Emoji>
+  onAnnotationEdited?: (question: string, answer: string, index: number) => void
+  onAnnotationAdded?: (annotationId: string, authorName: string, question: string, answer: string, index: number) => void
+  onAnnotationRemoved?: (index: number) => void
 }
 const Chat: FC<ChatProps> = ({
   config,
   onSend,
   chatList,
   isResponsing,
+  noStopResponding,
+  onStopResponding,
   noChatInput,
   chatContainerclassName,
   chatFooterClassName,
@@ -46,16 +57,46 @@ const Chat: FC<ChatProps> = ({
   questionIcon,
   answerIcon,
   allToolIcons,
+  onAnnotationAdded,
+  onAnnotationEdited,
+  onAnnotationRemoved,
 }) => {
-  const ref = useRef<HTMLDivElement>(null)
+  const { t } = useTranslation()
+  const chatContainerRef = useRef<HTMLDivElement>(null)
   const chatFooterRef = useRef<HTMLDivElement>(null)
 
+  const handleScrolltoBottom = () => {
+    if (chatContainerRef.current)
+      chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight
+  }
+
   useThrottleEffect(() => {
-    if (ref.current)
-      ref.current.scrollTop = ref.current.scrollHeight
+    handleScrolltoBottom()
+
+    if (chatContainerRef.current && chatFooterRef.current)
+      chatFooterRef.current.style.width = `${chatContainerRef.current.clientWidth}px`
   }, [chatList], { wait: 500 })
 
-  const hasTryToAsk = config.suggested_questions_after_answer?.enabled && !!suggestedQuestions?.length && onSend
+  useEffect(() => {
+    if (chatFooterRef.current && chatContainerRef.current) {
+      const resizeObserver = new ResizeObserver((entries) => {
+        for (const entry of entries) {
+          const { blockSize } = entry.borderBoxSize[0]
+
+          chatContainerRef.current!.style.paddingBottom = `${blockSize}px`
+          handleScrolltoBottom()
+        }
+      })
+
+      resizeObserver.observe(chatFooterRef.current)
+
+      return () => {
+        resizeObserver.disconnect()
+      }
+    }
+  }, [chatFooterRef, chatContainerRef])
+
+  const hasTryToAsk = config?.suggested_questions_after_answer?.enabled && !!suggestedQuestions?.length && onSend
 
   return (
     <ChatContextProvider
@@ -67,19 +108,24 @@ const Chat: FC<ChatProps> = ({
       answerIcon={answerIcon}
       allToolIcons={allToolIcons}
       onSend={onSend}
+      onAnnotationAdded={onAnnotationAdded}
+      onAnnotationEdited={onAnnotationEdited}
+      onAnnotationRemoved={onAnnotationRemoved}
     >
       <div className='relative h-full'>
         <div
-          ref={ref}
+          ref={chatContainerRef}
           className={`relative h-full overflow-y-auto ${chatContainerclassName}`}
         >
           {
-            chatList.map((item) => {
+            chatList.map((item, index) => {
               if (item.isAnswer) {
                 return (
                   <Answer
                     key={item.id}
                     item={item}
+                    question={chatList[index - 1]?.content}
+                    index={index}
                   />
                 )
               }
@@ -91,35 +137,41 @@ const Chat: FC<ChatProps> = ({
               )
             })
           }
+        </div>
+        <div
+          className={`absolute bottom-0 ${(hasTryToAsk || !noChatInput || !noStopResponding) && chatFooterClassName}`}
+          ref={chatFooterRef}
+          style={{
+            background: 'linear-gradient(0deg, #F9FAFB 40%, rgba(255, 255, 255, 0.00) 100%)',
+          }}
+        >
           {
-            (hasTryToAsk || !noChatInput) && (
-              <div
-                className={`sticky bottom-0 w-full backdrop-blur-[20px] ${chatFooterClassName}`}
-                ref={chatFooterRef}
-                style={{
-                  background: 'linear-gradient(0deg, #FFF 0%, rgba(255, 255, 255, 0.40) 100%)',
-                }}
-              >
-                {
-                  hasTryToAsk && (
-                    <TryToAsk
-                      suggestedQuestions={suggestedQuestions}
-                      onSend={onSend}
-                    />
-                  )
-                }
-                {
-                  !noChatInput && (
-                    <ChatInput
-                      visionConfig={config?.file_upload?.image}
-                      speechToTextConfig={config.speech_to_text}
-                      onSend={onSend}
-                    />
-                  )
-                }
+            !noStopResponding && isResponsing && (
+              <div className='flex justify-center mb-2'>
+                <Button className='py-0 px-3 h-7 bg-white shadow-xs' onClick={onStopResponding}>
+                  <StopCircle className='mr-[5px] w-3.5 h-3.5 text-gray-500' />
+                  <span className='text-xs text-gray-500 font-normal'>{t('appDebug.operation.stopResponding')}</span>
+                </Button>
               </div>
             )
           }
+          {
+            hasTryToAsk && (
+              <TryToAsk
+                suggestedQuestions={suggestedQuestions}
+                onSend={onSend}
+              />
+            )
+          }
+          {
+            !noChatInput && (
+              <ChatInput
+                visionConfig={config?.file_upload?.image}
+                speechToTextConfig={config?.speech_to_text}
+                onSend={onSend}
+              />
+            )
+          }
         </div>
       </div>
     </ChatContextProvider>

+ 4 - 1
web/app/components/base/chat/types.ts

@@ -41,7 +41,10 @@ export type EnableType = {
   enabled: boolean
 }
 
-export type ChatConfig = Omit<ModelConfig, 'model'>
+export type ChatConfig = Omit<ModelConfig, 'model'> & {
+  supportAnnotation?: boolean
+  appId?: string
+}
 
 export type ChatItem = IChatItem
 

+ 1 - 1
web/context/debug-configuration.ts

@@ -96,7 +96,7 @@ type IDebugConfiguration = {
   hasSetContextVar: boolean
   isShowVisionConfig: boolean
   visionConfig: VisionSettings
-  setVisionConfig: (visionConfig: VisionSettings) => void
+  setVisionConfig: (visionConfig: VisionSettings, noNotice?: boolean) => void
 }
 
 const DebugConfigurationContext = createContext<IDebugConfiguration>({