Browse Source

feat: chat in explore support agent (#647)

Co-authored-by: StyleZhang <jasonapring2015@outlook.com>
Joel 1 year ago
parent
commit
23e3413655
100 changed files with 3699 additions and 492 deletions
  1. 13 0
      web/app/(commonLayout)/explore/chat/page.tsx
  2. 10 4
      web/app/(commonLayout)/layout.tsx
  3. 262 0
      web/app/components/app/chat/answer/index.tsx
  4. 37 0
      web/app/components/app/chat/icon-component/index.tsx
  5. 21 370
      web/app/components/app/chat/index.tsx
  6. 19 0
      web/app/components/app/chat/more-info/index.tsx
  7. 14 0
      web/app/components/app/chat/operation/index.tsx
  8. 40 0
      web/app/components/app/chat/question/index.tsx
  9. 86 0
      web/app/components/app/chat/thought/index.tsx
  10. 7 0
      web/app/components/app/chat/thought/style.module.css
  11. 53 0
      web/app/components/app/chat/type.ts
  12. 11 8
      web/app/components/app/configuration/base/feature-panel/index.tsx
  13. 6 42
      web/app/components/app/configuration/config-model/index.tsx
  14. 26 0
      web/app/components/app/configuration/config-model/model-icon.tsx
  15. 5 7
      web/app/components/app/configuration/config/index.tsx
  16. 9 8
      web/app/components/app/configuration/dataset-config/card-item/index.tsx
  17. 6 2
      web/app/components/app/configuration/dataset-config/select-dataset/index.tsx
  18. 3 2
      web/app/components/app/configuration/debug/index.tsx
  19. 2 2
      web/app/components/app/log/list.tsx
  20. 12 0
      web/app/components/base/icons/assets/public/llm/anthropic.svg
  21. 5 0
      web/app/components/base/icons/assets/public/llm/gpt-3.svg
  22. 5 0
      web/app/components/base/icons/assets/public/llm/gpt-4.svg
  23. 3 0
      web/app/components/base/icons/assets/public/model/checked.svg
  24. 6 0
      web/app/components/base/icons/assets/public/plugins/google.svg
  25. 4 0
      web/app/components/base/icons/assets/public/plugins/web-reader.svg
  26. 3 0
      web/app/components/base/icons/assets/public/plugins/wikipedia.svg
  27. 10 0
      web/app/components/base/icons/assets/public/thought/data-set.svg
  28. 10 0
      web/app/components/base/icons/assets/public/thought/loading.svg
  29. 10 0
      web/app/components/base/icons/assets/public/thought/search.svg
  30. 8 0
      web/app/components/base/icons/assets/public/thought/thought-list.svg
  31. 10 0
      web/app/components/base/icons/assets/public/thought/web-reader.svg
  32. 5 0
      web/app/components/base/icons/assets/vender/line/general/link-external-02.svg
  33. 5 0
      web/app/components/base/icons/assets/vender/solid/alertsAndFeedback/alert-circle.svg
  34. 5 0
      web/app/components/base/icons/assets/vender/solid/general/check-circle.svg
  35. 87 0
      web/app/components/base/icons/src/public/llm/Anthropic.json
  36. 14 0
      web/app/components/base/icons/src/public/llm/Anthropic.tsx
  37. 51 0
      web/app/components/base/icons/src/public/llm/Gpt3.json
  38. 14 0
      web/app/components/base/icons/src/public/llm/Gpt3.tsx
  39. 51 0
      web/app/components/base/icons/src/public/llm/Gpt4.json
  40. 14 0
      web/app/components/base/icons/src/public/llm/Gpt4.tsx
  41. 3 0
      web/app/components/base/icons/src/public/llm/index.ts
  42. 29 0
      web/app/components/base/icons/src/public/model/Checked.json
  43. 14 0
      web/app/components/base/icons/src/public/model/Checked.tsx
  44. 1 0
      web/app/components/base/icons/src/public/model/index.ts
  45. 53 0
      web/app/components/base/icons/src/public/plugins/Google.json
  46. 14 0
      web/app/components/base/icons/src/public/plugins/Google.tsx
  47. 39 0
      web/app/components/base/icons/src/public/plugins/WebReader.json
  48. 14 0
      web/app/components/base/icons/src/public/plugins/WebReader.tsx
  49. 26 0
      web/app/components/base/icons/src/public/plugins/Wikipedia.json
  50. 14 0
      web/app/components/base/icons/src/public/plugins/Wikipedia.tsx
  51. 3 0
      web/app/components/base/icons/src/public/plugins/index.ts
  52. 64 0
      web/app/components/base/icons/src/public/thought/DataSet.json
  53. 14 0
      web/app/components/base/icons/src/public/thought/DataSet.tsx
  54. 64 0
      web/app/components/base/icons/src/public/thought/Loading.json
  55. 14 0
      web/app/components/base/icons/src/public/thought/Loading.tsx
  56. 64 0
      web/app/components/base/icons/src/public/thought/Search.json
  57. 14 0
      web/app/components/base/icons/src/public/thought/Search.tsx
  58. 83 0
      web/app/components/base/icons/src/public/thought/ThoughtList.json
  59. 14 0
      web/app/components/base/icons/src/public/thought/ThoughtList.tsx
  60. 64 0
      web/app/components/base/icons/src/public/thought/WebReader.json
  61. 14 0
      web/app/components/base/icons/src/public/thought/WebReader.tsx
  62. 5 0
      web/app/components/base/icons/src/public/thought/index.ts
  63. 38 0
      web/app/components/base/icons/src/vender/line/general/LinkExternal02.json
  64. 14 0
      web/app/components/base/icons/src/vender/line/general/LinkExternal02.tsx
  65. 1 0
      web/app/components/base/icons/src/vender/line/general/index.ts
  66. 38 0
      web/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertCircle.json
  67. 14 0
      web/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertCircle.tsx
  68. 1 0
      web/app/components/base/icons/src/vender/solid/alertsAndFeedback/index.ts
  69. 38 0
      web/app/components/base/icons/src/vender/solid/general/CheckCircle.json
  70. 14 0
      web/app/components/base/icons/src/vender/solid/general/CheckCircle.tsx
  71. 1 0
      web/app/components/base/icons/src/vender/solid/general/index.ts
  72. 51 39
      web/app/components/base/markdown.tsx
  73. 4 3
      web/app/components/base/toast/index.tsx
  74. 1 1
      web/app/components/base/voice-input/index.tsx
  75. 23 2
      web/app/components/explore/sidebar/index.tsx
  76. 34 0
      web/app/components/explore/universal-chat/config-view/detail/index.tsx
  77. 9 0
      web/app/components/explore/universal-chat/config-view/detail/style.module.css
  78. 84 0
      web/app/components/explore/universal-chat/config-view/summary/index.tsx
  79. 21 0
      web/app/components/explore/universal-chat/config-view/summary/style.module.css
  80. 95 0
      web/app/components/explore/universal-chat/config/data-config/index.tsx
  81. 51 0
      web/app/components/explore/universal-chat/config/index.tsx
  82. 61 0
      web/app/components/explore/universal-chat/config/model-config/index.tsx
  83. 111 0
      web/app/components/explore/universal-chat/config/plugins-config/index.tsx
  84. 3 0
      web/app/components/explore/universal-chat/config/plugins-config/item.module.css
  85. 43 0
      web/app/components/explore/universal-chat/config/plugins-config/item.tsx
  86. 72 0
      web/app/components/explore/universal-chat/hooks/use-conversation.ts
  87. 725 0
      web/app/components/explore/universal-chat/index.tsx
  88. 43 0
      web/app/components/explore/universal-chat/init/index.tsx
  89. 9 0
      web/app/components/explore/universal-chat/init/style.module.css
  90. 3 0
      web/app/components/explore/universal-chat/style.module.css
  91. 10 2
      web/app/components/header/account-setting/index.tsx
  92. 77 0
      web/app/components/header/account-setting/key-validator/KeyInput.tsx
  93. 85 0
      web/app/components/header/account-setting/key-validator/Operate.tsx
  94. 30 0
      web/app/components/header/account-setting/key-validator/ValidateStatus.tsx
  95. 43 0
      web/app/components/header/account-setting/key-validator/declarations.ts
  96. 32 0
      web/app/components/header/account-setting/key-validator/hooks.ts
  97. 119 0
      web/app/components/header/account-setting/key-validator/index.tsx
  98. 77 0
      web/app/components/header/account-setting/plugin-page/SerpapiPlugin.tsx
  99. 38 0
      web/app/components/header/account-setting/plugin-page/index.tsx
  100. 0 0
      web/app/components/header/account-setting/plugin-page/utils.ts

+ 13 - 0
web/app/(commonLayout)/explore/chat/page.tsx

@@ -0,0 +1,13 @@
+import type { FC } from 'react'
+import React from 'react'
+import UniversalChat from '@/app/components/explore/universal-chat'
+
+const Chat: FC = () => {
+  return (
+    <div className='h-full p-2'>
+      <UniversalChat />
+    </div>
+  )
+}
+
+export default React.memo(Chat)

+ 10 - 4
web/app/(commonLayout)/layout.tsx

@@ -5,6 +5,8 @@ import { AppContextProvider } from '@/context/app-context'
 import GA, { GaType } from '@/app/components/base/ga'
 import HeaderWrapper from '@/app/components/header/HeaderWrapper'
 import Header from '@/app/components/header'
+import { EventEmitterContextProvider } from '@/context/event-emitter'
+import { ProviderContextProvider } from '@/context/provider-context'
 
 const Layout = ({ children }: { children: ReactNode }) => {
   return (
@@ -12,10 +14,14 @@ const Layout = ({ children }: { children: ReactNode }) => {
       <GA gaType={GaType.admin} />
       <SwrInitor>
         <AppContextProvider>
-          <HeaderWrapper>
-            <Header />
-          </HeaderWrapper>
-          {children}
+          <EventEmitterContextProvider>
+            <ProviderContextProvider>
+              <HeaderWrapper>
+                <Header />
+              </HeaderWrapper>
+              {children}
+            </ProviderContextProvider>
+          </EventEmitterContextProvider>
         </AppContextProvider>
       </SwrInitor>
     </>

+ 262 - 0
web/app/components/app/chat/answer/index.tsx

@@ -0,0 +1,262 @@
+'use client'
+import type { FC } from 'react'
+import React, { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useContext } from 'use-context-selector'
+import { UserCircleIcon } from '@heroicons/react/24/solid'
+import cn from 'classnames'
+import type { DisplayScene, FeedbackFunc, Feedbacktype, IChatItem, SubmitAnnotationFunc, ThoughtItem } from '../type'
+import { randomString } from '../../../app-sidebar/basic'
+import OperationBtn from '../operation'
+import LoadingAnim from '../loading-anim'
+import { EditIcon, EditIconSolid, OpeningStatementIcon, RatingIcon } from '../icon-component'
+import s from '../style.module.css'
+import MoreInfo from '../more-info'
+import CopyBtn from '../copy-btn'
+import Thought from '../thought'
+import type { Annotation, MessageRating } from '@/models/log'
+import AppContext from '@/context/app-context'
+import Tooltip from '@/app/components/base/tooltip'
+import { Markdown } from '@/app/components/base/markdown'
+import AutoHeightTextarea from '@/app/components/base/auto-height-textarea'
+import Button from '@/app/components/base/button'
+import type { DataSet } from '@/models/datasets'
+const Divider: FC<{ name: string }> = ({ name }) => {
+  const { t } = useTranslation()
+  return <div className='flex items-center my-2'>
+    <span className='text-xs text-gray-500 inline-flex items-center mr-2'>
+      <EditIconSolid className='mr-1' />{t('appLog.detail.annotationTip', { user: name })}
+    </span>
+    <div className='h-[1px] bg-gray-200 flex-1'></div>
+  </div>
+}
+const IconWrapper: FC<{ children: React.ReactNode | string }> = ({ children }) => {
+  return <div className={'rounded-lg h-6 w-6 flex items-center justify-center hover:bg-gray-100'}>
+    {children}
+  </div>
+}
+export type IAnswerProps = {
+  item: IChatItem
+  feedbackDisabled: boolean
+  isHideFeedbackEdit: boolean
+  onFeedback?: FeedbackFunc
+  onSubmitAnnotation?: SubmitAnnotationFunc
+  displayScene: DisplayScene
+  isResponsing?: boolean
+  answerIconClassName?: string
+  thoughts?: ThoughtItem[]
+  isThinking?: boolean
+  dataSets?: DataSet[]
+}
+// The component needs to maintain its own state to control whether to display input component
+const Answer: FC<IAnswerProps> = ({ item, feedbackDisabled = false, isHideFeedbackEdit = false, onFeedback, onSubmitAnnotation, displayScene = 'web', isResponsing, answerIconClassName, thoughts, isThinking, dataSets }) => {
+  const { id, content, more, feedback, adminFeedback, annotation: initAnnotation } = item
+  const [showEdit, setShowEdit] = useState(false)
+  const [loading, setLoading] = useState(false)
+  const [annotation, setAnnotation] = useState<Annotation | undefined | null>(initAnnotation)
+  const [inputValue, setInputValue] = useState<string>(initAnnotation?.content ?? '')
+  const [localAdminFeedback, setLocalAdminFeedback] = useState<Feedbacktype | undefined | null>(adminFeedback)
+  const { userProfile } = useContext(AppContext)
+  const { t } = useTranslation()
+
+  /**
+ * Render feedback results (distinguish between users and administrators)
+ * User reviews cannot be cancelled in Console
+ * @param rating feedback result
+ * @param isUserFeedback Whether it is user's feedback
+ * @param isWebScene Whether it is web scene
+ * @returns comp
+ */
+  const renderFeedbackRating = (rating: MessageRating | undefined, isUserFeedback = true, isWebScene = true) => {
+    if (!rating)
+      return null
+
+    const isLike = rating === 'like'
+    const ratingIconClassname = isLike ? 'text-primary-600 bg-primary-100 hover:bg-primary-200' : 'text-red-600 bg-red-100 hover:bg-red-200'
+    const UserSymbol = <UserCircleIcon className='absolute top-[-2px] left-[18px] w-3 h-3 rounded-lg text-gray-400 bg-white' />
+    // The tooltip is always displayed, but the content is different for different scenarios.
+    return (
+      <Tooltip
+        selector={`user-feedback-${randomString(16)}`}
+        content={((isWebScene || (!isUserFeedback && !isWebScene)) ? isLike ? t('appDebug.operation.cancelAgree') : t('appDebug.operation.cancelDisagree') : (!isWebScene && isUserFeedback) ? `${t('appDebug.operation.userAction')}${isLike ? t('appDebug.operation.agree') : t('appDebug.operation.disagree')}` : '') as string}
+      >
+        <div
+          className={`relative box-border flex items-center justify-center h-7 w-7 p-0.5 rounded-lg bg-white cursor-pointer text-gray-500 hover:text-gray-800 ${(!isWebScene && isUserFeedback) ? '!cursor-default' : ''}`}
+          style={{ boxShadow: '0px 4px 6px -1px rgba(0, 0, 0, 0.1), 0px 2px 4px -2px rgba(0, 0, 0, 0.05)' }}
+          {...((isWebScene || (!isUserFeedback && !isWebScene))
+            ? {
+              onClick: async () => {
+                const res = await onFeedback?.(id, { rating: null })
+                if (res && !isWebScene)
+                  setLocalAdminFeedback({ rating: null })
+              },
+            }
+            : {})}
+        >
+          <div className={`${ratingIconClassname} rounded-lg h-6 w-6 flex items-center justify-center`}>
+            <RatingIcon isLike={isLike} />
+          </div>
+          {!isWebScene && isUserFeedback && UserSymbol}
+        </div>
+      </Tooltip>
+    )
+  }
+
+  /**
+   * Different scenarios have different operation items.
+   * @param isWebScene  Whether it is web scene
+   * @returns comp
+   */
+  const renderItemOperation = (isWebScene = true) => {
+    const userOperation = () => {
+      return feedback?.rating
+        ? null
+        : <div className='flex gap-1'>
+          <Tooltip selector={`user-feedback-${randomString(16)}`} content={t('appLog.detail.operation.like') as string}>
+            {OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={true} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'like' }) })}
+          </Tooltip>
+          <Tooltip selector={`user-feedback-${randomString(16)}`} content={t('appLog.detail.operation.dislike') as string}>
+            {OperationBtn({ innerContent: <IconWrapper><RatingIcon isLike={false} /></IconWrapper>, onClick: () => onFeedback?.(id, { rating: 'dislike' }) })}
+          </Tooltip>
+        </div>
+    }
+
+    const adminOperation = () => {
+      return <div className='flex gap-1'>
+        <Tooltip selector={`user-feedback-${randomString(16)}`} content={t('appLog.detail.operation.addAnnotation') as string}>
+          {OperationBtn({
+            innerContent: <IconWrapper><EditIcon className='hover:text-gray-800' /></IconWrapper>,
+            onClick: () => setShowEdit(true),
+          })}
+        </Tooltip>
+        {!localAdminFeedback?.rating && <>
+          <Tooltip selector={`user-feedback-${randomString(16)}`} content={t('appLog.detail.operation.like') as string}>
+            {OperationBtn({
+              innerContent: <IconWrapper><RatingIcon isLike={true} /></IconWrapper>,
+              onClick: async () => {
+                const res = await onFeedback?.(id, { rating: 'like' })
+                if (res)
+                  setLocalAdminFeedback({ rating: 'like' })
+              },
+            })}
+          </Tooltip>
+          <Tooltip selector={`user-feedback-${randomString(16)}`} content={t('appLog.detail.operation.dislike') as string}>
+            {OperationBtn({
+              innerContent: <IconWrapper><RatingIcon isLike={false} /></IconWrapper>,
+              onClick: async () => {
+                const res = await onFeedback?.(id, { rating: 'dislike' })
+                if (res)
+                  setLocalAdminFeedback({ rating: 'dislike' })
+              },
+            })}
+          </Tooltip>
+        </>}
+      </div>
+    }
+
+    return (
+      <div className={`${s.itemOperation} flex gap-2`}>
+        {isWebScene ? userOperation() : adminOperation()}
+      </div>
+    )
+  }
+
+  return (
+    <div key={id}>
+      <div className='flex items-start'>
+        <div className={`${s.answerIcon} ${answerIconClassName} w-10 h-10 shrink-0`}>
+          {isResponsing
+            && <div className={s.typeingIcon}>
+              <LoadingAnim type='avatar' />
+            </div>
+          }
+        </div>
+        <div className={s.answerWrapWrap}>
+          <div className={`${s.answerWrap} ${showEdit ? 'w-full' : ''}`}>
+            <div className={`${s.answer} relative text-sm text-gray-900`}>
+              <div className={'ml-2 py-3 px-4 bg-gray-100 rounded-tr-2xl rounded-b-2xl'}>
+                {item.isOpeningStatement && (
+                  <div className='flex items-center mb-1 gap-1'>
+                    <OpeningStatementIcon />
+                    <div className='text-xs text-gray-500'>{t('appDebug.openingStatement.title')}</div>
+                  </div>
+                )}
+                {(thoughts && thoughts.length > 0) && (
+                  <Thought
+                    list={thoughts || []}
+                    isThinking={isThinking}
+                    dataSets={dataSets}
+                  />
+                )}
+                {(isResponsing && !content)
+                  ? (
+                    <div className='flex items-center justify-center w-6 h-5'>
+                      <LoadingAnim type='text' />
+                    </div>
+                  )
+                  : (
+                    <div>
+                      <Markdown content={content} />
+                    </div>
+                  )}
+                {!showEdit
+                  ? (annotation?.content
+                    && <>
+                      <Divider name={annotation?.account?.name || userProfile?.name} />
+                      {annotation.content}
+                    </>)
+                  : <>
+                    <Divider name={annotation?.account?.name || userProfile?.name} />
+                    <AutoHeightTextarea
+                      placeholder={t('appLog.detail.operation.annotationPlaceholder') as string}
+                      value={inputValue}
+                      onChange={e => setInputValue(e.target.value)}
+                      minHeight={58}
+                      className={`${cn(s.textArea)} !py-2 resize-none block w-full !px-3 bg-gray-50 border border-gray-200 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm text-gray-700 tracking-[0.2px]`}
+                    />
+                    <div className="mt-2 flex flex-row">
+                      <Button
+                        type='primary'
+                        className='mr-2'
+                        loading={loading}
+                        onClick={async () => {
+                          if (!inputValue)
+                            return
+                          setLoading(true)
+                          const res = await onSubmitAnnotation?.(id, inputValue)
+                          if (res)
+                            setAnnotation({ ...annotation, content: inputValue } as any)
+                          setLoading(false)
+                          setShowEdit(false)
+                        }}>{t('common.operation.confirm')}</Button>
+                      <Button
+                        onClick={() => {
+                          setInputValue(annotation?.content ?? '')
+                          setShowEdit(false)
+                        }}>{t('common.operation.cancel')}</Button>
+                    </div>
+                  </>
+                }
+              </div>
+              <div className='absolute top-[-14px] right-[-14px] flex flex-row justify-end gap-1'>
+                {!item.isOpeningStatement && (
+                  <CopyBtn
+                    value={content}
+                    className={cn(s.copyBtn, 'mr-1')}
+                  />
+                )}
+                {!feedbackDisabled && !item.feedbackDisabled && renderItemOperation(displayScene !== 'console')}
+                {/* Admin feedback is displayed only in the background. */}
+                {!feedbackDisabled && renderFeedbackRating(localAdminFeedback?.rating, false, false)}
+                {/* User feedback must be displayed */}
+                {!feedbackDisabled && renderFeedbackRating(feedback?.rating, !isHideFeedbackEdit, displayScene !== 'console')}
+              </div>
+            </div>
+            {more && <MoreInfo more={more} isQuestion={false} />}
+          </div>
+        </div>
+      </div>
+    </div>
+  )
+}
+export default React.memo(Answer)

File diff suppressed because it is too large
+ 37 - 0
web/app/components/app/chat/icon-component/index.tsx


File diff suppressed because it is too large
+ 21 - 370
web/app/components/app/chat/index.tsx


+ 19 - 0
web/app/components/app/chat/more-info/index.tsx

@@ -0,0 +1,19 @@
+'use client'
+import type { FC } from 'react'
+import React from 'react'
+import { useTranslation } from 'react-i18next'
+import type { MessageMore } from '../type'
+import { formatNumber } from '@/utils/format'
+
+export type IMoreInfoProps = { more: MessageMore; isQuestion: boolean }
+
+const MoreInfo: FC<IMoreInfoProps> = ({ more, isQuestion }) => {
+  const { t } = useTranslation()
+  return (<div className={`mt-1 space-x-2 text-xs text-gray-400 ${isQuestion ? 'mr-2 text-right ' : 'ml-2 text-left float-right'}`}>
+    <span>{`${t('appLog.detail.timeConsuming')} ${more.latency}${t('appLog.detail.second')}`}</span>
+    <span>{`${t('appLog.detail.tokenCost')} ${formatNumber(more.tokens)}`}</span>
+    <span>· </span>
+    <span>{more.time} </span>
+  </div>)
+}
+export default React.memo(MoreInfo)

+ 14 - 0
web/app/components/app/chat/operation/index.tsx

@@ -0,0 +1,14 @@
+'use client'
+import React from 'react'
+
+const OperationBtn = ({ innerContent, onClick, className }: { innerContent: React.ReactNode; onClick?: () => void; className?: string }) => (
+  <div
+    className={`relative box-border flex items-center justify-center h-7 w-7 p-0.5 rounded-lg bg-white cursor-pointer text-gray-500 hover:text-gray-800 ${className ?? ''}`}
+    style={{ boxShadow: '0px 4px 6px -1px rgba(0, 0, 0, 0.1), 0px 2px 4px -2px rgba(0, 0, 0, 0.05)' }}
+    onClick={onClick && onClick}
+  >
+    {innerContent}
+  </div>
+)
+
+export default OperationBtn

+ 40 - 0
web/app/components/app/chat/question/index.tsx

@@ -0,0 +1,40 @@
+'use client'
+import type { FC } from 'react'
+import React from 'react'
+import { useContext } from 'use-context-selector'
+import s from '../style.module.css'
+import type { IChatItem } from '../type'
+import MoreInfo from '../more-info'
+import AppContext from '@/context/app-context'
+import { Markdown } from '@/app/components/base/markdown'
+
+type IQuestionProps = Pick<IChatItem, 'id' | 'content' | 'more' | 'useCurrentUserAvatar'>
+
+const Question: FC<IQuestionProps> = ({ id, content, more, useCurrentUserAvatar }) => {
+  const { userProfile } = useContext(AppContext)
+  const userName = userProfile?.name
+  return (
+    <div className='flex items-start justify-end' key={id}>
+      <div className={s.questionWrapWrap}>
+        <div className={`${s.question} relative text-sm text-gray-900`}>
+          <div
+            className={'mr-2 py-3 px-4 bg-blue-500 rounded-tl-2xl rounded-b-2xl'}
+          >
+            <Markdown content={content} />
+          </div>
+        </div>
+        {more && <MoreInfo more={more} isQuestion={true} />}
+      </div>
+      {useCurrentUserAvatar
+        ? (
+          <div className='w-10 h-10 shrink-0 leading-10 text-center mr-2 rounded-full bg-primary-600 text-white'>
+            {userName?.[0].toLocaleUpperCase()}
+          </div>
+        )
+        : (
+          <div className={`${s.questionIcon} w-10 h-10 shrink-0 `}></div>
+        )}
+    </div>
+  )
+}
+export default React.memo(Question)

+ 86 - 0
web/app/components/app/chat/thought/index.tsx

@@ -0,0 +1,86 @@
+'use client'
+import type { FC } from 'react'
+import React from 'react'
+import cn from 'classnames'
+import { useTranslation } from 'react-i18next'
+import type { ThoughtItem } from '../type'
+import s from './style.module.css'
+import { DataSet as DataSetIcon, Loading as LodingIcon, Search, ThoughtList, WebReader } from '@/app/components/base/icons/src/public/thought'
+import { ChevronDown } from '@/app/components/base/icons/src/vender/line/arrows'
+import type { DataSet } from '@/models/datasets'
+
+export type IThoughtProps = {
+  list: ThoughtItem[]
+  isThinking?: boolean
+  dataSets?: DataSet[]
+}
+
+const getIcon = (toolId: string) => {
+  switch (toolId) {
+    case 'dataset':
+      return <DataSetIcon />
+    case 'web_reader':
+      return <WebReader />
+    default:
+      return <Search />
+  }
+}
+
+const Thought: FC<IThoughtProps> = ({
+  list,
+  isThinking,
+  dataSets,
+}) => {
+  const { t } = useTranslation()
+  const [isShowDetail, setIsShowDetail] = React.useState(false)
+
+  const getThoughtText = (item: ThoughtItem) => {
+    try {
+      const input = JSON.parse(item.tool_input)
+
+      switch (item.tool) {
+        case 'dataset':
+          // eslint-disable-next-line no-case-declarations
+          const datasetName = dataSets?.find(item => item.id === input.dataset_id)?.name || 'unknown dataset'
+          return t('explore.universalChat.thought.res.dataset').replace('{datasetName}', `<span class="text-gray-700">${datasetName}</span>`)
+        case 'web_reader':
+          return t(`explore.universalChat.thought.res.webReader.${!input.cursor ? 'normal' : 'hasPageInfo'}`).replace('{url}', `<a href="${input.url}" class="text-[#155EEF]">${input.url}</a>`)
+        default: // google, wikipedia
+          return t('explore.universalChat.thought.res.search', { query: input.query })
+      }
+    }
+    catch (error) {
+      console.error(error)
+      return item
+    }
+  }
+  const renderItem = (item: ThoughtItem) => (
+    <div className='flex space-x-1 py-[3px] leading-[18px]' key={item.id}>
+      <div className='flex items-center h-[18px] shrink-0'>{getIcon(item.tool)}</div>
+      <div dangerouslySetInnerHTML={{
+        __html: getThoughtText(item),
+        // item.thought.replace(urlRegex, (url) => {
+        //   return `<a href="${url}" class="text-[#155EEF]">${url}</a>`
+        // }),
+      }}></div>
+    </div>
+  )
+  return (
+    <div className={cn(s.wrap, !isShowDetail && s.wrapHoverEffect, 'inline-block mb-2 px-2 py-0.5 rounded-md text-xs text-gray-500 font-medium')} >
+      <div className='flex items-center h-6 space-x-1 cursor-pointer' onClick={() => setIsShowDetail(!isShowDetail)} >
+        {!isThinking ? <ThoughtList /> : <div className='animate-spin'><LodingIcon /></div>}
+        <div dangerouslySetInnerHTML= {{
+          __html: isThinking ? getThoughtText(list[list.length - 1]) : (t(`explore.universalChat.thought.${isShowDetail ? 'hide' : 'show'}`) + t('explore.universalChat.thought.processOfThought')),
+        }}
+        ></div>
+        <ChevronDown className={isShowDetail ? 'rotate-180' : '' } />
+      </div>
+      {isShowDetail && (
+        <div>
+          {list.map(item => renderItem(item))}
+        </div>
+      )}
+    </div>
+  )
+}
+export default React.memo(Thought)

+ 7 - 0
web/app/components/app/chat/thought/style.module.css

@@ -0,0 +1,7 @@
+.wrap {
+  background-color: rgba(255, 255, 255, 0.92);
+}
+
+.wrapHoverEffect:hover{
+  box-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.06), 0px 1px 3px 0px rgba(16, 24, 40, 0.1);
+}

+ 53 - 0
web/app/components/app/chat/type.ts

@@ -0,0 +1,53 @@
+import type { Annotation, MessageRating } from '@/models/log'
+
+export type MessageMore = {
+  time: string
+  tokens: number
+  latency: number | string
+}
+
+export type Feedbacktype = {
+  rating: MessageRating
+  content?: string | null
+}
+
+export type FeedbackFunc = (messageId: string, feedback: Feedbacktype) => Promise<any>
+export type SubmitAnnotationFunc = (messageId: string, content: string) => Promise<any>
+
+export type DisplayScene = 'web' | 'console'
+
+export type ThoughtItem = {
+  id: string
+  tool: string // plugin or dataset
+  thought: string
+  tool_input: string
+  message_id: string
+}
+export type IChatItem = {
+  id: string
+  content: string
+  agent_thoughts?: ThoughtItem[]
+  /**
+   * Specific message type
+   */
+  isAnswer: boolean
+  /**
+   * The user feedback result of this message
+   */
+  feedback?: Feedbacktype
+  /**
+   * The admin feedback result of this message
+   */
+  adminFeedback?: Feedbacktype
+  /**
+   * Whether to hide the feedback area
+   */
+  feedbackDisabled?: boolean
+  /**
+   * More information about this message
+   */
+  more?: MessageMore
+  annotation?: Annotation
+  useCurrentUserAvatar?: boolean
+  isOpeningStatement?: boolean
+}

+ 11 - 8
web/app/components/app/configuration/base/feature-panel/index.tsx

@@ -1,12 +1,13 @@
 'use client'
-import React, { FC, ReactNode } from 'react'
+import type { FC, ReactNode } from 'react'
+import React from 'react'
 import cn from 'classnames'
 
-export interface IFeaturePanelProps {
+export type IFeaturePanelProps = {
   className?: string
-  headerIcon: ReactNode
+  headerIcon?: ReactNode
   title: ReactNode
-  headerRight: ReactNode
+  headerRight?: ReactNode
   hasHeaderBottomBorder?: boolean
   isFocus?: boolean
   noBodySpacing?: boolean
@@ -26,15 +27,17 @@ const FeaturePanel: FC<IFeaturePanelProps> = ({
   return (
     <div
       className={cn(className, isFocus && 'border border-[#2D0DEE]', 'rounded-xl bg-gray-50 pt-2 pb-3', noBodySpacing && '!pb-0')}
-      style={isFocus ? {
-        boxShadow: '0px 4px 8px -2px rgba(16, 24, 40, 0.1), 0px 2px 4px -2px rgba(16, 24, 40, 0.06)',
-      } : {}}
+      style={isFocus
+        ? {
+          boxShadow: '0px 4px 8px -2px rgba(16, 24, 40, 0.1), 0px 2px 4px -2px rgba(16, 24, 40, 0.06)',
+        }
+        : {}}
     >
       {/* Header */}
       <div className={cn('pb-2 px-3', hasHeaderBottomBorder && 'border-b border-gray-100')}>
         <div className='flex justify-between items-center h-8'>
           <div className='flex items-center space-x-1 shrink-0'>
-            <div className='flex items-center justify-center w-4 h-4'>{headerIcon}</div>
+            {headerIcon && <div className='flex items-center justify-center w-4 h-4'>{headerIcon}</div>}
             <div className='text-sm font-semibold text-gray-800'>{title}</div>
           </div>
           <div>

File diff suppressed because it is too large
+ 6 - 42
web/app/components/app/configuration/config-model/index.tsx


+ 26 - 0
web/app/components/app/configuration/config-model/model-icon.tsx

@@ -0,0 +1,26 @@
+'use client'
+import type { FC } from 'react'
+import React from 'react'
+import { ProviderType } from '@/types/app'
+import { MODEL_LIST } from '@/config'
+import { Anthropic, Gpt3, Gpt4 } from '@/app/components/base/icons/src/public/llm'
+
+export type IModelIconProps = { modelId: string; className?: string }
+
+const ModelIcon: FC<IModelIconProps> = ({ modelId, className }) => {
+  const resClassName = `w-4 h-4 ${className}`
+  const model = MODEL_LIST.find(item => item.id === modelId)
+  if (model?.id === 'gpt-4')
+    return <Gpt4 className={resClassName} />
+
+  if (model?.provider === ProviderType.anthropic) {
+    return (
+      <Anthropic className={resClassName} />
+    )
+  }
+  return (
+    <Gpt3 className={resClassName} />
+  )
+}
+
+export default React.memo(ModelIcon)

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

@@ -4,7 +4,6 @@ import React from 'react'
 import { useContext } from 'use-context-selector'
 import produce from 'immer'
 import { useBoolean } from 'ahooks'
-import useSWR from 'swr'
 import DatasetConfig from '../dataset-config'
 import ChatGroup from '../features/chat-group'
 import ExperienceEnchanceGroup from '../features/experience-enchance-group'
@@ -20,7 +19,7 @@ import ConfigPrompt from '@/app/components/app/configuration/config-prompt'
 import ConfigVar from '@/app/components/app/configuration/config-var'
 import type { PromptVariable } from '@/models/debug'
 import { AppType } from '@/types/app'
-import { fetchTenantInfo } from '@/service/common'
+import { useProviderContext } from '@/context/provider-context'
 
 const Config: FC = () => {
   const {
@@ -39,8 +38,7 @@ const Config: FC = () => {
     setSpeechToTextConfig,
   } = useContext(ConfigContext)
   const isChatApp = mode === AppType.chat
-  const { data: userInfo } = useSWR({ url: '/info' }, fetchTenantInfo)
-  const openaiProvider = userInfo?.providers?.find(({ token_is_set, is_valid, provider_name }) => token_is_set && is_valid && provider_name === 'openai')
+  const { currentProvider } = useProviderContext()
 
   const promptTemplate = modelConfig.configs.prompt_template
   const promptVariables = modelConfig.configs.prompt_variables
@@ -92,7 +90,7 @@ const Config: FC = () => {
     },
   })
 
-  const hasChatConfig = isChatApp && (featureConfig.openingStatement || featureConfig.suggestedQuestionsAfterAnswer || (featureConfig.speechToText && openaiProvider))
+  const hasChatConfig = isChatApp && (featureConfig.openingStatement || featureConfig.suggestedQuestionsAfterAnswer || (featureConfig.speechToText && currentProvider?.provider_name === 'openai'))
   const hasToolbox = false
 
   const [showAutomatic, { setTrue: showAutomaticTrue, setFalse: showAutomaticFalse }] = useBoolean(false)
@@ -122,7 +120,7 @@ const Config: FC = () => {
             isChatApp={isChatApp}
             config={featureConfig}
             onChange={handleFeatureChange}
-            showSpeechToTextItem={!!openaiProvider}
+            showSpeechToTextItem={currentProvider?.provider_name === 'openai'}
           />
         )}
         {showAutomatic && (
@@ -162,7 +160,7 @@ const Config: FC = () => {
                 }
               }
               isShowSuggestedQuestionsAfterAnswer={featureConfig.suggestedQuestionsAfterAnswer}
-              isShowSpeechText={featureConfig.speechToText}
+              isShowSpeechText={featureConfig.speechToText && currentProvider?.provider_name === 'openai'}
             />
           )
         }

+ 9 - 8
web/app/components/app/configuration/dataset-config/card-item/index.tsx

@@ -1,20 +1,20 @@
 'use client'
-import React, { FC } from 'react'
+import type { FC } from 'react'
+import React from 'react'
 import cn from 'classnames'
-import TypeIcon from '../type-icon'
 import { useTranslation } from 'react-i18next'
-import { formatNumber } from '@/utils/format'
+import TypeIcon from '../type-icon'
 import RemoveIcon from '../../base/icons/remove-icon'
 import s from './style.module.css'
+import { formatNumber } from '@/utils/format'
 
-export interface ICardItemProps {
+export type ICardItemProps = {
   className?: string
   config: any
   onRemove: (id: string) => void
+  readonly?: boolean
 }
 
-
-
 // const RemoveIcon = ({ className, onClick }: { className: string, onClick: () => void }) => (
 //   <svg className={className} onClick={onClick} width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
 //     <path d="M10 6H14M6 8H18M16.6667 8L16.1991 15.0129C16.129 16.065 16.0939 16.5911 15.8667 16.99C15.6666 17.3412 15.3648 17.6235 15.0011 17.7998C14.588 18 14.0607 18 13.0062 18H10.9938C9.93927 18 9.41202 18 8.99889 17.7998C8.63517 17.6235 8.33339 17.3412 8.13332 16.99C7.90607 16.5911 7.871 16.065 7.80086 15.0129L7.33333 8M10.6667 11V14.3333M13.3333 11V14.3333" stroke="#667085" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
@@ -24,7 +24,8 @@ export interface ICardItemProps {
 const CardItem: FC<ICardItemProps> = ({
   className,
   config,
-  onRemove
+  onRemove,
+  readonly,
 }) => {
   const { t } = useTranslation()
 
@@ -44,7 +45,7 @@ const CardItem: FC<ICardItemProps> = ({
         </div>
       </div>
 
-      <RemoveIcon className={`${s.deleteBtn} shrink-0`} onClick={() => onRemove(config.id)} />
+      {!readonly && <RemoveIcon className={`${s.deleteBtn} shrink-0`} onClick={() => onRemove(config.id)} />}
     </div>
   )
 }

+ 6 - 2
web/app/components/app/configuration/dataset-config/select-dataset/index.tsx

@@ -128,8 +128,12 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
                   <div className='max-w-[200px] text-[13px] font-medium text-gray-800 overflow-hidden text-ellipsis whitespace-nowrap'>{item.name}</div>
                 </div>
 
-                <div className='max-w-[140px] flex text-xs text-gray-500  overflow-hidden text-ellipsis whitespace-nowrap'>
-                  {formatNumber(item.word_count)} {t('appDebug.feature.dataSet.words')} · {formatNumber(item.document_count)} {t('appDebug.feature.dataSet.textBlocks')}
+                <div className='flex text-xs text-gray-500 overflow-hidden whitespace-nowrap'>
+                  <span className='max-w-[100px] overflow-hidden text-ellipsis whitespace-nowrap'>{formatNumber(item.word_count)}</span>
+                  {t('appDebug.feature.dataSet.words')}
+                  <span className='px-0.5'>·</span>
+                  <span className='max-w-[100px] min-w-[8px] overflow-hidden text-ellipsis whitespace-nowrap'>{formatNumber(item.document_count)} </span>
+                  {t('appDebug.feature.dataSet.textBlocks')}
                 </div>
               </div>
             ))}

+ 3 - 2
web/app/components/app/configuration/debug/index.tsx

@@ -22,6 +22,7 @@ import type { ModelConfig as BackendModelConfig } from '@/types/app'
 import { promptVariablesToUserInputsForm } from '@/utils/model-config'
 import TextGeneration from '@/app/components/app/text-generate/item'
 import { IS_CE_EDITION } from '@/config'
+import { useProviderContext } from '@/context/provider-context'
 
 type IDebug = {
   hasSetAPIKEY: boolean
@@ -51,7 +52,7 @@ const Debug: FC<IDebug> = ({
     modelConfig,
     completionParams,
   } = useContext(ConfigContext)
-
+  const { currentProvider } = useProviderContext()
   const [chatList, setChatList, getChatList] = useGetState<IChatItem[]>([])
   const chatListDomRef = useRef<HTMLDivElement>(null)
   useEffect(() => {
@@ -389,7 +390,7 @@ const Debug: FC<IDebug> = ({
                   }}
                   isShowSuggestion={doShowSuggestion}
                   suggestionList={suggestQuestions}
-                  isShowSpeechToText={speechToTextConfig.enabled}
+                  isShowSpeechToText={speechToTextConfig.enabled && currentProvider?.provider_name === 'openai'}
                 />
               </div>
             </div>

+ 2 - 2
web/app/components/app/log/list.tsx

@@ -16,10 +16,10 @@ import dayjs from 'dayjs'
 import { createContext, useContext } from 'use-context-selector'
 import classNames from 'classnames'
 import { useTranslation } from 'react-i18next'
-import { EditIconSolid } from '../chat'
 import { randomString } from '../../app-sidebar/basic'
 import s from './style.module.css'
-import type { FeedbackFunc, Feedbacktype, IChatItem, SubmitAnnotationFunc } from '@/app/components/app/chat'
+import { EditIconSolid } from '@/app/components/app/chat/icon-component'
+import type { FeedbackFunc, Feedbacktype, IChatItem, SubmitAnnotationFunc } from '@/app/components/app/chat/type'
 import type { Annotation, ChatConversationFullDetailResponse, ChatConversationGeneralDetail, ChatConversationsResponse, ChatMessage, ChatMessagesRequest, CompletionConversationFullDetailResponse, CompletionConversationGeneralDetail, CompletionConversationsResponse } from '@/models/log'
 import type { App } from '@/types/app'
 import Loading from '@/app/components/base/loading'

+ 12 - 0
web/app/components/base/icons/assets/public/llm/anthropic.svg

@@ -0,0 +1,12 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect width="24" height="24" rx="6" fill="#CA9F7B"/>
+<g clip-path="url(#clip0_7672_55906)">
+<path d="M15.3843 6.43457H12.9687L17.3739 17.565H19.7896L15.3843 6.43457ZM8.40522 6.43457L4 17.565H6.4633L7.36417 15.2276H11.9729L12.8737 17.565H15.337L10.9318 6.43457H8.40522ZM8.16104 13.1605L9.66852 9.24883L11.176 13.1605H8.16104Z" fill="#191918"/>
+</g>
+<rect x="0.5" y="0.5" width="23" height="23" rx="5.5" stroke="black" stroke-opacity="0.05"/>
+<defs>
+<clipPath id="clip0_7672_55906">
+<rect width="16" height="11.1304" fill="white" transform="translate(4 6.43457)"/>
+</clipPath>
+</defs>
+</svg>

File diff suppressed because it is too large
+ 5 - 0
web/app/components/base/icons/assets/public/llm/gpt-3.svg


File diff suppressed because it is too large
+ 5 - 0
web/app/components/base/icons/assets/public/llm/gpt-4.svg


+ 3 - 0
web/app/components/base/icons/assets/public/model/checked.svg

@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M13.3332 4L5.99984 11.3333L2.6665 8" stroke="#155EEF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 6 - 0
web/app/components/base/icons/assets/public/plugins/google.svg

@@ -0,0 +1,6 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M22.501 12.2331C22.501 11.3698 22.4296 10.7398 22.2748 10.0864H12.2153V13.983H18.12C18.001 14.9514 17.3582 16.4097 15.9296 17.3897L15.9096 17.5202L19.0902 19.9349L19.3106 19.9564C21.3343 18.1247 22.501 15.4297 22.501 12.2331Z" fill="#4285F4"/>
+<path d="M12.2147 22.5001C15.1075 22.5001 17.5361 21.5667 19.3099 19.9567L15.929 17.39C15.0242 18.0083 13.8099 18.44 12.2147 18.44C9.38142 18.44 6.97669 16.6083 6.11947 14.0767L5.99382 14.0871L2.68656 16.5955L2.64331 16.7133C4.40519 20.1433 8.02423 22.5001 12.2147 22.5001Z" fill="#34A853"/>
+<path d="M6.11997 14.0765C5.89379 13.4232 5.76289 12.7231 5.76289 11.9998C5.76289 11.2764 5.89379 10.5765 6.10807 9.92313L6.10208 9.78398L2.75337 7.23535L2.64381 7.28642C1.91765 8.70977 1.50098 10.3081 1.50098 11.9998C1.50098 13.6915 1.91765 15.2897 2.64381 16.7131L6.11997 14.0765Z" fill="#FBBC05"/>
+<path d="M12.2148 5.55997C14.2267 5.55997 15.5838 6.41163 16.3576 7.12335L19.3814 4.23C17.5243 2.53834 15.1076 1.5 12.2148 1.5C8.02426 1.5 4.4052 3.85665 2.64331 7.28662L6.10759 9.92332C6.97672 7.39166 9.38146 5.55997 12.2148 5.55997Z" fill="#EB4335"/>
+</svg>

File diff suppressed because it is too large
+ 4 - 0
web/app/components/base/icons/assets/public/plugins/web-reader.svg


File diff suppressed because it is too large
+ 3 - 0
web/app/components/base/icons/assets/public/plugins/wikipedia.svg


+ 10 - 0
web/app/components/base/icons/assets/public/thought/data-set.svg

@@ -0,0 +1,10 @@
+<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_7847_32895)">
+<path d="M10.5 2.5C10.5 3.32843 8.48528 4 6 4C3.51472 4 1.5 3.32843 1.5 2.5M10.5 2.5C10.5 1.67157 8.48528 1 6 1C3.51472 1 1.5 1.67157 1.5 2.5M10.5 2.5V9.5C10.5 10.33 8.5 11 6 11C3.5 11 1.5 10.33 1.5 9.5V2.5M10.5 6C10.5 6.83 8.5 7.5 6 7.5C3.5 7.5 1.5 6.83 1.5 6" stroke="#667085" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<defs>
+<clipPath id="clip0_7847_32895">
+<rect width="12" height="12" fill="white"/>
+</clipPath>
+</defs>
+</svg>

+ 10 - 0
web/app/components/base/icons/assets/public/thought/loading.svg

@@ -0,0 +1,10 @@
+<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_7998_4025)">
+<path d="M6 1.125V2.375M6 9V11M2.875 6H1.125M10.625 6H9.875M9.22855 9.22855L8.875 8.875M9.33211 2.70789L8.625 3.415M2.46079 9.53921L3.875 8.125M2.56434 2.60434L3.625 3.665" stroke="#667085" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<defs>
+<clipPath id="clip0_7998_4025">
+<rect width="12" height="12" fill="white"/>
+</clipPath>
+</defs>
+</svg>

+ 10 - 0
web/app/components/base/icons/assets/public/thought/search.svg

@@ -0,0 +1,10 @@
+<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_7847_32899)">
+<path d="M10.5 10.5L8.75005 8.75M10 5.75C10 8.09721 8.09721 10 5.75 10C3.40279 10 1.5 8.09721 1.5 5.75C1.5 3.40279 3.40279 1.5 5.75 1.5C8.09721 1.5 10 3.40279 10 5.75Z" stroke="#667085" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+<defs>
+<clipPath id="clip0_7847_32899">
+<rect width="12" height="12" fill="white"/>
+</clipPath>
+</defs>
+</svg>

+ 8 - 0
web/app/components/base/icons/assets/public/thought/thought-list.svg

@@ -0,0 +1,8 @@
+<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M4 6C4 5.72386 4.22386 5.5 4.5 5.5L10.5 5.5C10.7761 5.5 11 5.72386 11 6C11 6.27614 10.7761 6.5 10.5 6.5L4.5 6.5C4.22386 6.5 4 6.27614 4 6Z" fill="#667085"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M4 3C4 2.72386 4.22386 2.5 4.5 2.5L10.5 2.5C10.7761 2.5 11 2.72386 11 3C11 3.27614 10.7761 3.5 10.5 3.5L4.5 3.5C4.22386 3.5 4 3.27614 4 3Z" fill="#667085"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M4 9C4 8.72386 4.22386 8.5 4.5 8.5L10.5 8.5C10.7761 8.5 11 8.72386 11 9C11 9.27614 10.7761 9.5 10.5 9.5L4.5 9.5C4.22386 9.5 4 9.27614 4 9Z" fill="#667085"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M1 6C1 5.44772 1.44772 5 2 5C2.55228 5 3 5.44772 3 6C3 6.55228 2.55228 7 2 7C1.44772 7 1 6.55228 1 6Z" fill="#667085"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M1 3C1 2.44772 1.44772 2 2 2C2.55228 2 3 2.44772 3 3C3 3.55228 2.55228 4 2 4C1.44772 4 1 3.55228 1 3Z" fill="#667085"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M1 9C1 8.44772 1.44772 8 2 8C2.55228 8 3 8.44772 3 9C3 9.55228 2.55228 10 2 10C1.44772 10 1 9.55228 1 9Z" fill="#667085"/>
+</svg>

File diff suppressed because it is too large
+ 10 - 0
web/app/components/base/icons/assets/public/thought/web-reader.svg


File diff suppressed because it is too large
+ 5 - 0
web/app/components/base/icons/assets/vender/line/general/link-external-02.svg


File diff suppressed because it is too large
+ 5 - 0
web/app/components/base/icons/assets/vender/solid/alertsAndFeedback/alert-circle.svg


File diff suppressed because it is too large
+ 5 - 0
web/app/components/base/icons/assets/vender/solid/general/check-circle.svg


+ 87 - 0
web/app/components/base/icons/src/public/llm/Anthropic.json

@@ -0,0 +1,87 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "24",
+			"height": "24",
+			"viewBox": "0 0 24 24",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "rect",
+				"attributes": {
+					"width": "24",
+					"height": "24",
+					"rx": "6",
+					"fill": "#CA9F7B"
+				},
+				"children": []
+			},
+			{
+				"type": "element",
+				"name": "g",
+				"attributes": {
+					"clip-path": "url(#clip0_7672_55906)"
+				},
+				"children": [
+					{
+						"type": "element",
+						"name": "path",
+						"attributes": {
+							"d": "M15.3843 6.43457H12.9687L17.3739 17.565H19.7896L15.3843 6.43457ZM8.40522 6.43457L4 17.565H6.4633L7.36417 15.2276H11.9729L12.8737 17.565H15.337L10.9318 6.43457H8.40522ZM8.16104 13.1605L9.66852 9.24883L11.176 13.1605H8.16104Z",
+							"fill": "#191918"
+						},
+						"children": []
+					}
+				]
+			},
+			{
+				"type": "element",
+				"name": "rect",
+				"attributes": {
+					"x": "0.5",
+					"y": "0.5",
+					"width": "23",
+					"height": "23",
+					"rx": "5.5",
+					"stroke": "black",
+					"stroke-opacity": "0.05"
+				},
+				"children": []
+			},
+			{
+				"type": "element",
+				"name": "defs",
+				"attributes": {},
+				"children": [
+					{
+						"type": "element",
+						"name": "clipPath",
+						"attributes": {
+							"id": "clip0_7672_55906"
+						},
+						"children": [
+							{
+								"type": "element",
+								"name": "rect",
+								"attributes": {
+									"width": "16",
+									"height": "11.1304",
+									"fill": "white",
+									"transform": "translate(4 6.43457)"
+								},
+								"children": []
+							}
+						]
+					}
+				]
+			}
+		]
+	},
+	"name": "Anthropic"
+}

+ 14 - 0
web/app/components/base/icons/src/public/llm/Anthropic.tsx

@@ -0,0 +1,14 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Anthropic.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+export default Icon

File diff suppressed because it is too large
+ 51 - 0
web/app/components/base/icons/src/public/llm/Gpt3.json


+ 14 - 0
web/app/components/base/icons/src/public/llm/Gpt3.tsx

@@ -0,0 +1,14 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Gpt3.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+export default Icon

File diff suppressed because it is too large
+ 51 - 0
web/app/components/base/icons/src/public/llm/Gpt4.json


+ 14 - 0
web/app/components/base/icons/src/public/llm/Gpt4.tsx

@@ -0,0 +1,14 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Gpt4.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+export default Icon

+ 3 - 0
web/app/components/base/icons/src/public/llm/index.ts

@@ -0,0 +1,3 @@
+export { default as Anthropic } from './Anthropic'
+export { default as Gpt3 } from './Gpt3'
+export { default as Gpt4 } from './Gpt4'

+ 29 - 0
web/app/components/base/icons/src/public/model/Checked.json

@@ -0,0 +1,29 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "16",
+			"height": "16",
+			"viewBox": "0 0 16 16",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "path",
+				"attributes": {
+					"d": "M13.3332 4L5.99984 11.3333L2.6665 8",
+					"stroke": "#155EEF",
+					"stroke-width": "2",
+					"stroke-linecap": "round",
+					"stroke-linejoin": "round"
+				},
+				"children": []
+			}
+		]
+	},
+	"name": "Checked"
+}

+ 14 - 0
web/app/components/base/icons/src/public/model/Checked.tsx

@@ -0,0 +1,14 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Checked.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+export default Icon

+ 1 - 0
web/app/components/base/icons/src/public/model/index.ts

@@ -0,0 +1 @@
+export { default as Checked } from './Checked'

+ 53 - 0
web/app/components/base/icons/src/public/plugins/Google.json

@@ -0,0 +1,53 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "24",
+			"height": "24",
+			"viewBox": "0 0 24 24",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "path",
+				"attributes": {
+					"d": "M22.501 12.2331C22.501 11.3698 22.4296 10.7398 22.2748 10.0864H12.2153V13.983H18.12C18.001 14.9514 17.3582 16.4097 15.9296 17.3897L15.9096 17.5202L19.0902 19.9349L19.3106 19.9564C21.3343 18.1247 22.501 15.4297 22.501 12.2331Z",
+					"fill": "#4285F4"
+				},
+				"children": []
+			},
+			{
+				"type": "element",
+				"name": "path",
+				"attributes": {
+					"d": "M12.2147 22.5001C15.1075 22.5001 17.5361 21.5667 19.3099 19.9567L15.929 17.39C15.0242 18.0083 13.8099 18.44 12.2147 18.44C9.38142 18.44 6.97669 16.6083 6.11947 14.0767L5.99382 14.0871L2.68656 16.5955L2.64331 16.7133C4.40519 20.1433 8.02423 22.5001 12.2147 22.5001Z",
+					"fill": "#34A853"
+				},
+				"children": []
+			},
+			{
+				"type": "element",
+				"name": "path",
+				"attributes": {
+					"d": "M6.11997 14.0765C5.89379 13.4232 5.76289 12.7231 5.76289 11.9998C5.76289 11.2764 5.89379 10.5765 6.10807 9.92313L6.10208 9.78398L2.75337 7.23535L2.64381 7.28642C1.91765 8.70977 1.50098 10.3081 1.50098 11.9998C1.50098 13.6915 1.91765 15.2897 2.64381 16.7131L6.11997 14.0765Z",
+					"fill": "#FBBC05"
+				},
+				"children": []
+			},
+			{
+				"type": "element",
+				"name": "path",
+				"attributes": {
+					"d": "M12.2148 5.55997C14.2267 5.55997 15.5838 6.41163 16.3576 7.12335L19.3814 4.23C17.5243 2.53834 15.1076 1.5 12.2148 1.5C8.02426 1.5 4.4052 3.85665 2.64331 7.28662L6.10759 9.92332C6.97672 7.39166 9.38146 5.55997 12.2148 5.55997Z",
+					"fill": "#EB4335"
+				},
+				"children": []
+			}
+		]
+	},
+	"name": "Google"
+}

+ 14 - 0
web/app/components/base/icons/src/public/plugins/Google.tsx

@@ -0,0 +1,14 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Google.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+export default Icon

File diff suppressed because it is too large
+ 39 - 0
web/app/components/base/icons/src/public/plugins/WebReader.json


+ 14 - 0
web/app/components/base/icons/src/public/plugins/WebReader.tsx

@@ -0,0 +1,14 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './WebReader.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+export default Icon

File diff suppressed because it is too large
+ 26 - 0
web/app/components/base/icons/src/public/plugins/Wikipedia.json


+ 14 - 0
web/app/components/base/icons/src/public/plugins/Wikipedia.tsx

@@ -0,0 +1,14 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Wikipedia.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+export default Icon

+ 3 - 0
web/app/components/base/icons/src/public/plugins/index.ts

@@ -0,0 +1,3 @@
+export { default as Google } from './Google'
+export { default as WebReader } from './WebReader'
+export { default as Wikipedia } from './Wikipedia'

+ 64 - 0
web/app/components/base/icons/src/public/thought/DataSet.json

@@ -0,0 +1,64 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "12",
+			"height": "12",
+			"viewBox": "0 0 12 12",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "g",
+				"attributes": {
+					"clip-path": "url(#clip0_7847_32895)"
+				},
+				"children": [
+					{
+						"type": "element",
+						"name": "path",
+						"attributes": {
+							"d": "M10.5 2.5C10.5 3.32843 8.48528 4 6 4C3.51472 4 1.5 3.32843 1.5 2.5M10.5 2.5C10.5 1.67157 8.48528 1 6 1C3.51472 1 1.5 1.67157 1.5 2.5M10.5 2.5V9.5C10.5 10.33 8.5 11 6 11C3.5 11 1.5 10.33 1.5 9.5V2.5M10.5 6C10.5 6.83 8.5 7.5 6 7.5C3.5 7.5 1.5 6.83 1.5 6",
+							"stroke": "#667085",
+							"stroke-width": "1.25",
+							"stroke-linecap": "round",
+							"stroke-linejoin": "round"
+						},
+						"children": []
+					}
+				]
+			},
+			{
+				"type": "element",
+				"name": "defs",
+				"attributes": {},
+				"children": [
+					{
+						"type": "element",
+						"name": "clipPath",
+						"attributes": {
+							"id": "clip0_7847_32895"
+						},
+						"children": [
+							{
+								"type": "element",
+								"name": "rect",
+								"attributes": {
+									"width": "12",
+									"height": "12",
+									"fill": "white"
+								},
+								"children": []
+							}
+						]
+					}
+				]
+			}
+		]
+	},
+	"name": "DataSet"
+}

+ 14 - 0
web/app/components/base/icons/src/public/thought/DataSet.tsx

@@ -0,0 +1,14 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './DataSet.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+export default Icon

+ 64 - 0
web/app/components/base/icons/src/public/thought/Loading.json

@@ -0,0 +1,64 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "12",
+			"height": "12",
+			"viewBox": "0 0 12 12",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "g",
+				"attributes": {
+					"clip-path": "url(#clip0_7998_4025)"
+				},
+				"children": [
+					{
+						"type": "element",
+						"name": "path",
+						"attributes": {
+							"d": "M6 1.125V2.375M6 9V11M2.875 6H1.125M10.625 6H9.875M9.22855 9.22855L8.875 8.875M9.33211 2.70789L8.625 3.415M2.46079 9.53921L3.875 8.125M2.56434 2.60434L3.625 3.665",
+							"stroke": "#667085",
+							"stroke-width": "1.25",
+							"stroke-linecap": "round",
+							"stroke-linejoin": "round"
+						},
+						"children": []
+					}
+				]
+			},
+			{
+				"type": "element",
+				"name": "defs",
+				"attributes": {},
+				"children": [
+					{
+						"type": "element",
+						"name": "clipPath",
+						"attributes": {
+							"id": "clip0_7998_4025"
+						},
+						"children": [
+							{
+								"type": "element",
+								"name": "rect",
+								"attributes": {
+									"width": "12",
+									"height": "12",
+									"fill": "white"
+								},
+								"children": []
+							}
+						]
+					}
+				]
+			}
+		]
+	},
+	"name": "Loading"
+}

+ 14 - 0
web/app/components/base/icons/src/public/thought/Loading.tsx

@@ -0,0 +1,14 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Loading.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+export default Icon

+ 64 - 0
web/app/components/base/icons/src/public/thought/Search.json

@@ -0,0 +1,64 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "12",
+			"height": "12",
+			"viewBox": "0 0 12 12",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "g",
+				"attributes": {
+					"clip-path": "url(#clip0_7847_32899)"
+				},
+				"children": [
+					{
+						"type": "element",
+						"name": "path",
+						"attributes": {
+							"d": "M10.5 10.5L8.75005 8.75M10 5.75C10 8.09721 8.09721 10 5.75 10C3.40279 10 1.5 8.09721 1.5 5.75C1.5 3.40279 3.40279 1.5 5.75 1.5C8.09721 1.5 10 3.40279 10 5.75Z",
+							"stroke": "#667085",
+							"stroke-width": "1.25",
+							"stroke-linecap": "round",
+							"stroke-linejoin": "round"
+						},
+						"children": []
+					}
+				]
+			},
+			{
+				"type": "element",
+				"name": "defs",
+				"attributes": {},
+				"children": [
+					{
+						"type": "element",
+						"name": "clipPath",
+						"attributes": {
+							"id": "clip0_7847_32899"
+						},
+						"children": [
+							{
+								"type": "element",
+								"name": "rect",
+								"attributes": {
+									"width": "12",
+									"height": "12",
+									"fill": "white"
+								},
+								"children": []
+							}
+						]
+					}
+				]
+			}
+		]
+	},
+	"name": "Search"
+}

+ 14 - 0
web/app/components/base/icons/src/public/thought/Search.tsx

@@ -0,0 +1,14 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Search.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+export default Icon

+ 83 - 0
web/app/components/base/icons/src/public/thought/ThoughtList.json

@@ -0,0 +1,83 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "12",
+			"height": "12",
+			"viewBox": "0 0 12 12",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "path",
+				"attributes": {
+					"fill-rule": "evenodd",
+					"clip-rule": "evenodd",
+					"d": "M4 6C4 5.72386 4.22386 5.5 4.5 5.5L10.5 5.5C10.7761 5.5 11 5.72386 11 6C11 6.27614 10.7761 6.5 10.5 6.5L4.5 6.5C4.22386 6.5 4 6.27614 4 6Z",
+					"fill": "#667085"
+				},
+				"children": []
+			},
+			{
+				"type": "element",
+				"name": "path",
+				"attributes": {
+					"fill-rule": "evenodd",
+					"clip-rule": "evenodd",
+					"d": "M4 3C4 2.72386 4.22386 2.5 4.5 2.5L10.5 2.5C10.7761 2.5 11 2.72386 11 3C11 3.27614 10.7761 3.5 10.5 3.5L4.5 3.5C4.22386 3.5 4 3.27614 4 3Z",
+					"fill": "#667085"
+				},
+				"children": []
+			},
+			{
+				"type": "element",
+				"name": "path",
+				"attributes": {
+					"fill-rule": "evenodd",
+					"clip-rule": "evenodd",
+					"d": "M4 9C4 8.72386 4.22386 8.5 4.5 8.5L10.5 8.5C10.7761 8.5 11 8.72386 11 9C11 9.27614 10.7761 9.5 10.5 9.5L4.5 9.5C4.22386 9.5 4 9.27614 4 9Z",
+					"fill": "#667085"
+				},
+				"children": []
+			},
+			{
+				"type": "element",
+				"name": "path",
+				"attributes": {
+					"fill-rule": "evenodd",
+					"clip-rule": "evenodd",
+					"d": "M1 6C1 5.44772 1.44772 5 2 5C2.55228 5 3 5.44772 3 6C3 6.55228 2.55228 7 2 7C1.44772 7 1 6.55228 1 6Z",
+					"fill": "#667085"
+				},
+				"children": []
+			},
+			{
+				"type": "element",
+				"name": "path",
+				"attributes": {
+					"fill-rule": "evenodd",
+					"clip-rule": "evenodd",
+					"d": "M1 3C1 2.44772 1.44772 2 2 2C2.55228 2 3 2.44772 3 3C3 3.55228 2.55228 4 2 4C1.44772 4 1 3.55228 1 3Z",
+					"fill": "#667085"
+				},
+				"children": []
+			},
+			{
+				"type": "element",
+				"name": "path",
+				"attributes": {
+					"fill-rule": "evenodd",
+					"clip-rule": "evenodd",
+					"d": "M1 9C1 8.44772 1.44772 8 2 8C2.55228 8 3 8.44772 3 9C3 9.55228 2.55228 10 2 10C1.44772 10 1 9.55228 1 9Z",
+					"fill": "#667085"
+				},
+				"children": []
+			}
+		]
+	},
+	"name": "ThoughtList"
+}

+ 14 - 0
web/app/components/base/icons/src/public/thought/ThoughtList.tsx

@@ -0,0 +1,14 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './ThoughtList.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+export default Icon

File diff suppressed because it is too large
+ 64 - 0
web/app/components/base/icons/src/public/thought/WebReader.json


+ 14 - 0
web/app/components/base/icons/src/public/thought/WebReader.tsx

@@ -0,0 +1,14 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './WebReader.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+export default Icon

+ 5 - 0
web/app/components/base/icons/src/public/thought/index.ts

@@ -0,0 +1,5 @@
+export { default as DataSet } from './DataSet'
+export { default as Loading } from './Loading'
+export { default as Search } from './Search'
+export { default as ThoughtList } from './ThoughtList'
+export { default as WebReader } from './WebReader'

+ 38 - 0
web/app/components/base/icons/src/vender/line/general/LinkExternal02.json

@@ -0,0 +1,38 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "12",
+			"height": "12",
+			"viewBox": "0 0 12 12",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "g",
+				"attributes": {
+					"id": "link-external-02"
+				},
+				"children": [
+					{
+						"type": "element",
+						"name": "path",
+						"attributes": {
+							"id": "Icon",
+							"d": "M10.5 4.5L10.5 1.5M10.5 1.5H7.49999M10.5 1.5L6 6M5 1.5H3.9C3.05992 1.5 2.63988 1.5 2.31901 1.66349C2.03677 1.8073 1.8073 2.03677 1.66349 2.31901C1.5 2.63988 1.5 3.05992 1.5 3.9V8.1C1.5 8.94008 1.5 9.36012 1.66349 9.68099C1.8073 9.96323 2.03677 10.1927 2.31901 10.3365C2.63988 10.5 3.05992 10.5 3.9 10.5H8.1C8.94008 10.5 9.36012 10.5 9.68099 10.3365C9.96323 10.1927 10.1927 9.96323 10.3365 9.68099C10.5 9.36012 10.5 8.94008 10.5 8.1V7",
+							"stroke": "currentColor",
+							"stroke-linecap": "round",
+							"stroke-linejoin": "round"
+						},
+						"children": []
+					}
+				]
+			}
+		]
+	},
+	"name": "LinkExternal02"
+}

+ 14 - 0
web/app/components/base/icons/src/vender/line/general/LinkExternal02.tsx

@@ -0,0 +1,14 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './LinkExternal02.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+export default Icon

+ 1 - 0
web/app/components/base/icons/src/vender/line/general/index.ts

@@ -1,4 +1,5 @@
 export { default as Check } from './Check'
+export { default as LinkExternal02 } from './LinkExternal02'
 export { default as Loading02 } from './Loading02'
 export { default as LogOut01 } from './LogOut01'
 export { default as Trash03 } from './Trash03'

File diff suppressed because it is too large
+ 38 - 0
web/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertCircle.json


+ 14 - 0
web/app/components/base/icons/src/vender/solid/alertsAndFeedback/AlertCircle.tsx

@@ -0,0 +1,14 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './AlertCircle.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+export default Icon

+ 1 - 0
web/app/components/base/icons/src/vender/solid/alertsAndFeedback/index.ts

@@ -1 +1,2 @@
+export { default as AlertCircle } from './AlertCircle'
 export { default as AlertTriangle } from './AlertTriangle'

File diff suppressed because it is too large
+ 38 - 0
web/app/components/base/icons/src/vender/solid/general/CheckCircle.json


+ 14 - 0
web/app/components/base/icons/src/vender/solid/general/CheckCircle.tsx

@@ -0,0 +1,14 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './CheckCircle.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+export default Icon

+ 1 - 0
web/app/components/base/icons/src/vender/solid/general/index.ts

@@ -1,2 +1,3 @@
+export { default as CheckCircle } from './CheckCircle'
 export { default as Download02 } from './Download02'
 export { default as XCircle } from './XCircle'

+ 51 - 39
web/app/components/base/markdown.tsx

@@ -1,16 +1,26 @@
-import ReactMarkdown from "react-markdown";
-import "katex/dist/katex.min.css";
-import RemarkMath from "remark-math";
-import RemarkBreaks from "remark-breaks";
-import RehypeKatex from "rehype-katex";
-import RemarkGfm from "remark-gfm";
+import ReactMarkdown from 'react-markdown'
+import 'katex/dist/katex.min.css'
+import RemarkMath from 'remark-math'
+import RemarkBreaks from 'remark-breaks'
+import RehypeKatex from 'rehype-katex'
+import RemarkGfm from 'remark-gfm'
 import SyntaxHighlighter from 'react-syntax-highlighter'
 import { atelierHeathLight } from 'react-syntax-highlighter/dist/esm/styles/hljs'
-import { useRef, useState, RefObject, useEffect } from "react";
+import type { RefObject } from 'react'
+import { useEffect, useRef, useState } from 'react'
 // import { copyToClipboard } from "../utils";
+// https://txtfiddle.com/~hlshwya/extract-urls-from-text
+// const urlRegex = /\b((https?|ftp|file):\/\/|(www|ftp)\.)[-A-Z0-9+&@#\/%?=~_|$!:,.;]*[A-Z0-9+&@#\/%=~_|$]/ig
 
+// function highlightURL(content: string) {
+//   return content.replace(urlRegex, (url) => {
+//     // fix http:// in [] will be parsed to link agin
+//     const res = `[${url.replace('://', ':&#47;&#47;')}](${url})`
+//     return res
+//   })
+// }
 export function PreCode(props: { children: any }) {
-  const ref = useRef<HTMLPreElement>(null);
+  const ref = useRef<HTMLPreElement>(null)
 
   return (
     <pre ref={ref}>
@@ -18,38 +28,37 @@ export function PreCode(props: { children: any }) {
         className="copy-code-button"
         onClick={() => {
           if (ref.current) {
-            const code = ref.current.innerText;
+            const code = ref.current.innerText
             // copyToClipboard(code);
           }
         }}
       ></span>
       {props.children}
     </pre>
-  );
+  )
 }
 
 const useLazyLoad = (ref: RefObject<Element>): boolean => {
-  const [isIntersecting, setIntersecting] = useState<boolean>(false);
+  const [isIntersecting, setIntersecting] = useState<boolean>(false)
 
   useEffect(() => {
     const observer = new IntersectionObserver(([entry]) => {
       if (entry.isIntersecting) {
-        setIntersecting(true);
-        observer.disconnect();
+        setIntersecting(true)
+        observer.disconnect()
       }
-    });
+    })
 
-    if (ref.current) {
-      observer.observe(ref.current);
-    }
+    if (ref.current)
+      observer.observe(ref.current)
 
     return () => {
-      observer.disconnect();
-    };
-  }, [ref]);
+      observer.disconnect()
+    }
+  }, [ref])
 
-  return isIntersecting;
-};
+  return isIntersecting
+}
 
 export function Markdown(props: { content: string }) {
   return (
@@ -62,26 +71,29 @@ export function Markdown(props: { content: string }) {
         components={{
           code({ node, inline, className, children, ...props }) {
             const match = /language-(\w+)/.exec(className || '')
-            return !inline && match ? (
-              <SyntaxHighlighter
-                {...props}
-                children={String(children).replace(/\n$/, '')}
-                style={atelierHeathLight}
-                language={match[1]}
-                showLineNumbers
-                PreTag="div"
-              />
-            ) : (
-              <code {...props} className={className}>
-                {children}
-              </code>
-            )
-          }
+            return (!inline && match)
+              ? (
+                <SyntaxHighlighter
+                  {...props}
+                  children={String(children).replace(/\n$/, '')}
+                  style={atelierHeathLight}
+                  language={match[1]}
+                  showLineNumbers
+                  PreTag="div"
+                />
+              )
+              : (
+                <code {...props} className={className}>
+                  {children}
+                </code>
+              )
+          },
         }}
-        linkTarget={"_blank"}
+        linkTarget={'_blank'}
       >
+        {/* Markdown detect has problem. */}
         {props.content}
       </ReactMarkdown>
     </div>
-  );
+  )
 }

+ 4 - 3
web/app/components/base/toast/index.tsx

@@ -9,7 +9,7 @@ import {
   InformationCircleIcon,
   XCircleIcon,
 } from '@heroicons/react/20/solid'
-import { createContext } from 'use-context-selector'
+import { createContext, useContext } from 'use-context-selector'
 
 export type IToastProps = {
   type?: 'success' | 'error' | 'warning' | 'info'
@@ -24,6 +24,7 @@ type IToastContext = {
 const defaultDuring = 3000
 
 export const ToastContext = createContext<IToastContext>({} as IToastContext)
+export const useToastContext = () => useContext(ToastContext)
 const Toast = ({
   type = 'info',
   duration,
@@ -31,9 +32,9 @@ const Toast = ({
   children,
 }: IToastProps) => {
   // sometimes message is react node array. Not handle it.
-  if (typeof message !== 'string') {
+  if (typeof message !== 'string')
     return null
-  }
+
   return <div className={classNames(
     'fixed rounded-md p-4 my-4 mx-8 z-50',
     'top-0',

+ 1 - 1
web/app/components/base/voice-input/index.tsx

@@ -86,7 +86,7 @@ const VoiceInput = ({
     const formData = new FormData()
     formData.append('file', mp3File)
 
-    let url = ''
+    let url = '/universal-chat/audio-to-text'
     let isPublic = false
 
     if (params.token) {

File diff suppressed because it is too large
+ 23 - 2
web/app/components/explore/sidebar/index.tsx


+ 34 - 0
web/app/components/explore/universal-chat/config-view/detail/index.tsx

@@ -0,0 +1,34 @@
+'use client'
+import type { FC } from 'react'
+import React from 'react'
+import cn from 'classnames'
+import { useTranslation } from 'react-i18next'
+import s from './style.module.css'
+import Config from '@/app/components/explore/universal-chat/config'
+
+type Props = {
+  modelId: string
+  plugins: Record<string, boolean>
+  dataSets: any[]
+}
+const ConfigViewPanel: FC<Props> = ({
+  modelId,
+  plugins,
+  dataSets,
+}) => {
+  const { t } = useTranslation()
+  return (
+    <div className={cn('absolute top-9 right-0 z-20 p-4 bg-white rounded-2xl shadow-md', s.panelBorder)}>
+      <div className='w-[368px]'>
+        <Config
+          readonly
+          modelId={modelId}
+          plugins={plugins}
+          dataSets={dataSets}
+        />
+        <div className='mt-3 text-xs leading-[18px] text-500 font-normal'>{t('explore.universalChat.viewConfigDetailTip')}</div>
+      </div>
+    </div>
+  )
+}
+export default React.memo(ConfigViewPanel)

+ 9 - 0
web/app/components/explore/universal-chat/config-view/detail/style.module.css

@@ -0,0 +1,9 @@
+.btn {
+  background: url(~@/app/components/datasets/documents/assets/action.svg) center center no-repeat transparent;
+  background-size: 16px 16px;
+  /* mask-image: ; */
+}
+
+.panelBorder {
+  border: 0.5px solid rgba(0, 0, 0, .05);
+}

+ 84 - 0
web/app/components/explore/universal-chat/config-view/summary/index.tsx

@@ -0,0 +1,84 @@
+'use client'
+import type { FC } from 'react'
+import React from 'react'
+import cn from 'classnames'
+import { useBoolean, useClickAway } from 'ahooks'
+import s from './style.module.css'
+import ModelIcon from '@/app/components/app/configuration/config-model/model-icon'
+import { Google, WebReader, Wikipedia } from '@/app/components/base/icons/src/public/plugins'
+import ConfigDetail from '@/app/components/explore/universal-chat/config-view/detail'
+
+export type ISummaryProps = {
+  modelId: string
+  plugins: Record<string, boolean>
+  dataSets: any[]
+}
+
+const getColorInfo = (modelId: string) => {
+  if (modelId === 'gpt-4')
+    return s.gpt4
+
+  if (modelId === 'claude-2')
+    return s.claude
+
+  return s.gpt3
+}
+
+const getPlugIcon = (pluginId: string) => {
+  const className = 'w-4 h-4'
+  switch (pluginId) {
+    case 'google_search':
+      return <Google className={className} />
+    case 'web_reader':
+      return <WebReader className={className} />
+    case 'wikipedia':
+      return <Wikipedia className={className} />
+    default:
+      return null
+  }
+}
+
+const Summary: FC<ISummaryProps> = ({
+  modelId,
+  plugins,
+  dataSets,
+}) => {
+  const pluginIds = Object.keys(plugins).filter(key => plugins[key])
+  const [isShowConfig, { setFalse: hideConfig, toggle: toggleShowConfig }] = useBoolean(false)
+  const configContentRef = React.useRef(null)
+
+  useClickAway(() => {
+    hideConfig()
+  }, configContentRef)
+  return (
+    <div ref={configContentRef} className='relative'>
+      <div onClick={toggleShowConfig} className={cn(getColorInfo(modelId), 'flex items-center px-1 h-8 rounded-lg border cursor-pointer')}>
+        <ModelIcon modelId={modelId} className='!w-6 !h-6' />
+        <div className='ml-2 text-[13px] font-medium text-gray-900'>{modelId}</div>
+        {
+          pluginIds.length > 0 && (
+            <div className='ml-1.5 flex items-center'>
+              <div className='mr-1 h-3 w-[1px] bg-[#000] opacity-[0.05]'></div>
+              <div className='flex space-x-1'>
+                {pluginIds.map(pluginId => (
+                  <div
+                    key={pluginId}
+                    className={`flex items-center justify-center w-6 h-6 rounded-md ${s.border} bg-white`}
+                  >
+                    {getPlugIcon(pluginId)}</div>
+                ))}
+              </div>
+            </div>
+          )
+        }
+      </div>
+      {isShowConfig && (
+        <ConfigDetail
+          modelId={modelId} plugins={plugins} dataSets={dataSets}
+        />
+      )}
+    </div>
+
+  )
+}
+export default React.memo(Summary)

+ 21 - 0
web/app/components/explore/universal-chat/config-view/summary/style.module.css

@@ -0,0 +1,21 @@
+.border {
+  border: 1px solid rgba(0, 0, 0, 0.05);
+}
+
+.gpt3 {
+  background: linear-gradient(0deg, #D3F8DF, #D3F8DF),
+linear-gradient(0deg, #EDFCF2, #EDFCF2);
+  border: 1px solid rgba(211, 248, 223, 1)
+}
+
+.gpt4 {
+  background: linear-gradient(0deg, #EBE9FE, #EBE9FE),
+  linear-gradient(0deg, #F4F3FF, #F4F3FF);
+  border: 1px solid rgba(235, 233, 254, 1)
+}
+
+.claude {
+  background: linear-gradient(0deg, #F9EBDF, #F9EBDF),
+linear-gradient(0deg, #FCF3EB, #FCF3EB);
+  border: 1px solid rgba(249, 235, 223, 1)
+}

+ 95 - 0
web/app/components/explore/universal-chat/config/data-config/index.tsx

@@ -0,0 +1,95 @@
+'use client'
+import type { FC } from 'react'
+import React from 'react'
+import { useTranslation } from 'react-i18next'
+import { useBoolean } from 'ahooks'
+import { isEqual } from 'lodash-es'
+import produce from 'immer'
+import FeaturePanel from '@/app/components/app/configuration/base/feature-panel'
+import OperationBtn from '@/app/components/app/configuration/base/operation-btn'
+import CardItem from '@/app/components/app/configuration/dataset-config/card-item'
+import SelectDataSet from '@/app/components/app/configuration/dataset-config/select-dataset'
+import type { DataSet } from '@/models/datasets'
+
+type Props = {
+  readonly?: boolean
+  dataSets: DataSet[]
+  onChange?: (data: DataSet[]) => void
+}
+
+const DatasetConfig: FC<Props> = ({
+  readonly,
+  dataSets,
+  onChange,
+}) => {
+  const { t } = useTranslation()
+
+  const selectedIds = dataSets.map(item => item.id)
+
+  const hasData = dataSets.length > 0
+  const [isShowSelectDataSet, { setTrue: showSelectDataSet, setFalse: hideSelectDataSet }] = useBoolean(false)
+  const handleSelect = (data: DataSet[]) => {
+    if (isEqual(data.map(item => item.id), dataSets.map(item => item.id))) {
+      hideSelectDataSet()
+      return
+    }
+
+    if (data.find(item => !item.name)) { // has not loaded selected dataset
+      const newSelected = produce(data, (draft) => {
+        data.forEach((item, index) => {
+          if (!item.name) { // not fetched database
+            const newItem = dataSets.find(i => i.id === item.id)
+            if (newItem)
+              draft[index] = newItem
+          }
+        })
+      })
+      onChange?.(newSelected)
+    }
+    else {
+      onChange?.(data)
+    }
+    hideSelectDataSet()
+  }
+  const onRemove = (id: string) => {
+    onChange?.(dataSets.filter(item => item.id !== id))
+  }
+
+  return (
+    <FeaturePanel
+      className='mt-3'
+      title={t('appDebug.feature.dataSet.title')}
+      headerRight={!readonly && <OperationBtn type="add" onClick={showSelectDataSet} />}
+      hasHeaderBottomBorder={!hasData}
+    >
+      {hasData
+        ? (
+          <div className='max-h-[220px] overflow-y-auto'>
+            {dataSets.map(item => (
+              <CardItem
+                className="mb-2 !w-full"
+                key={item.id}
+                config={item}
+                onRemove={onRemove}
+                readonly={readonly}
+                // TODO: readonly remove btn
+              />
+            ))}
+          </div>
+        )
+        : (
+          <div className='pt-2 pb-1 text-xs text-gray-500'>{t('appDebug.feature.dataSet.noData')}</div>
+        )}
+
+      {isShowSelectDataSet && (
+        <SelectDataSet
+          isShow={isShowSelectDataSet}
+          onClose={hideSelectDataSet}
+          selectedIds={selectedIds}
+          onSelect={handleSelect}
+        />
+      )}
+    </FeaturePanel>
+  )
+}
+export default React.memo(DatasetConfig)

+ 51 - 0
web/app/components/explore/universal-chat/config/index.tsx

@@ -0,0 +1,51 @@
+'use client'
+import type { FC } from 'react'
+import React from 'react'
+import ModelConfig from './model-config'
+import DataConfig from './data-config'
+import PluginConfig from './plugins-config'
+
+export type IConfigProps = {
+  className?: string
+  readonly?: boolean
+  modelId: string
+  onModelChange?: (modelId: string) => void
+  plugins: Record<string, boolean>
+  onPluginChange?: (key: string, value: boolean) => void
+  dataSets: any[]
+  onDataSetsChange?: (contexts: any[]) => void
+}
+
+const Config: FC<IConfigProps> = ({
+  className,
+  readonly,
+  modelId,
+  onModelChange,
+  plugins,
+  onPluginChange,
+  dataSets,
+  onDataSetsChange,
+}) => {
+  return (
+    <div className={className}>
+      <ModelConfig
+        readonly={readonly}
+        modelId={modelId}
+        onChange={onModelChange}
+      />
+      <PluginConfig
+        readonly={readonly}
+        config={plugins}
+        onChange={onPluginChange}
+      />
+      {(!readonly || (readonly && dataSets.length > 0)) && (
+        <DataConfig
+          readonly={readonly}
+          dataSets={dataSets}
+          onChange={onDataSetsChange}
+        />
+      )}
+    </div>
+  )
+}
+export default React.memo(Config)

+ 61 - 0
web/app/components/explore/universal-chat/config/model-config/index.tsx

@@ -0,0 +1,61 @@
+'use client'
+import type { FC } from 'react'
+import React from 'react'
+import cn from 'classnames'
+import { useBoolean, useClickAway } from 'ahooks'
+import { ChevronDownIcon } from '@heroicons/react/24/outline'
+import { useTranslation } from 'react-i18next'
+import ModelIcon from '@/app/components/app/configuration/config-model/model-icon'
+import { UNIVERSAL_CHAT_MODEL_LIST as MODEL_LIST } from '@/config'
+import { Checked as CheckedIcon } from '@/app/components/base/icons/src/public/model'
+export type IModelConfigProps = {
+  modelId: string
+  onChange?: (model: string) => void
+  readonly?: boolean
+}
+
+const ModelConfig: FC<IModelConfigProps> = ({
+  modelId,
+  onChange,
+  readonly,
+}) => {
+  const { t } = useTranslation()
+
+  const currModel = MODEL_LIST.find(item => item.id === modelId)
+  const [isShowOption, { setFalse: hideOption, toggle: toogleOption }] = useBoolean(false)
+  const triggerRef = React.useRef(null)
+  useClickAway(() => {
+    hideOption()
+  }, triggerRef)
+
+  return (
+    <div className='flex items-center justify-between h-[52px] px-3 rounded-xl bg-gray-50'>
+      <div className='text-sm font-semibold text-gray-800'>{t('explore.universalChat.model')}</div>
+      <div className="relative z-10">
+        <div
+          ref={triggerRef}
+          onClick={() => !readonly && toogleOption()}
+          className={cn(
+            readonly ? 'cursor-not-allowed' : 'cursor-pointer', 'flex items-center h-9 px-3 space-x-2 rounded-lg',
+            isShowOption && 'bg-gray-100',
+          )}>
+          <ModelIcon modelId={currModel?.id as string} />
+          <div className="text-sm gray-900">{currModel?.name}</div>
+          {!readonly && <ChevronDownIcon className={cn(isShowOption && 'rotate-180', 'w-[14px] h-[14px] text-gray-500')} />}
+        </div>
+        {isShowOption && (
+          <div className={cn('absolute top-10 right-0 bg-white rounded-lg shadow')}>
+            {MODEL_LIST.map(item => (
+              <div key={item.id} onClick={() => onChange?.(item.id)} className="w-[232px] flex items-center h-9 px-4 rounded-lg cursor-pointer hover:bg-gray-100">
+                <ModelIcon className='shrink-0 mr-2' modelId={item?.id} />
+                <div className="text-sm gray-900 whitespace-nowrap">{item.name}</div>
+                {(item.id === currModel?.id) && <CheckedIcon className='absolute right-4' />}
+              </div>
+            ))}
+          </div>
+        )}
+      </div>
+    </div>
+  )
+}
+export default React.memo(ModelConfig)

+ 111 - 0
web/app/components/explore/universal-chat/config/plugins-config/index.tsx

@@ -0,0 +1,111 @@
+'use client'
+import type { FC } from 'react'
+import React, { useEffect } from 'react'
+import { useTranslation } from 'react-i18next'
+import Item from './item'
+import FeaturePanel from '@/app/components/app/configuration/base/feature-panel'
+import { Google, WebReader, Wikipedia } from '@/app/components/base/icons/src/public/plugins'
+import { getToolProviders } from '@/service/explore'
+import Loading from '@/app/components/base/loading'
+import AccountSetting from '@/app/components/header/account-setting'
+
+export type IPluginsProps = {
+  readonly?: boolean
+  config: Record<string, boolean>
+  onChange?: (key: string, value: boolean) => void
+}
+
+const plugins = [
+  { key: 'google_search', icon: <Google /> },
+  { key: 'web_reader', icon: <WebReader /> },
+  { key: 'wikipedia', icon: <Wikipedia /> },
+]
+const Plugins: FC<IPluginsProps> = ({
+  readonly,
+  config,
+  onChange,
+}) => {
+  const { t } = useTranslation()
+  const [isLoading, setIsLoading] = React.useState(!readonly)
+  const [isSerpApiValid, setIsSerpApiValid] = React.useState(false)
+  const checkSerpApiKey = async () => {
+    if (readonly)
+      return
+
+    const provides: any = await getToolProviders()
+    const isSerpApiValid = !!provides.find((v: any) => v.tool_name === 'serpapi' && v.is_enabled)
+    setIsSerpApiValid(isSerpApiValid)
+    setIsLoading(false)
+  }
+  useEffect(() => {
+    checkSerpApiKey()
+  }, [])
+
+  const [showSetSerpAPIKeyModal, setShowSetAPIKeyModal] = React.useState(false)
+
+  const itemConfigs = plugins.map((plugin) => {
+    const res: Record<string, any> = { ...plugin }
+    const { key } = plugin
+    res.name = t(`explore.universalChat.plugins.${key}.name`)
+    if (key === 'web_reader')
+      res.description = t(`explore.universalChat.plugins.${key}.description`)
+
+    if (key === 'google_search' && !isSerpApiValid && !readonly) {
+      res.readonly = true
+      res.more = (
+        <div className='border-t border-[#FEF0C7] flex items-center h-[34px] pl-2 bg-[#FFFAEB] text-gray-700 text-xs '>
+          <span className='whitespace-pre'>{t('explore.universalChat.plugins.google_search.more.left')}</span>
+          <span className='cursor-pointer text-[#155EEF]' onClick={() => setShowSetAPIKeyModal(true)}>{t('explore.universalChat.plugins.google_search.more.link')}</span>
+          <span className='whitespace-pre'>{t('explore.universalChat.plugins.google_search.more.right')}</span>
+        </div>
+      )
+    }
+    return res
+  })
+
+  const enabledPluginNum = Object.values(config).filter(v => v).length
+
+  return (
+    <>
+      <FeaturePanel
+        className='mt-3'
+        title={
+          <div className='flex space-x-1'>
+            <div>{t('explore.universalChat.plugins.name')}</div>
+            <div className='text-[13px] font-normal text-gray-500'>({enabledPluginNum}/{plugins.length})</div>
+          </div>}
+        hasHeaderBottomBorder={false}
+      >
+        {isLoading
+          ? (
+            <div className='flex items-center h-[166px]'>
+              <Loading type='area' />
+            </div>
+          )
+          : (<div className='space-y-2'>
+            {itemConfigs.map(item => (
+              <Item
+                key={item.key}
+                icon={item.icon}
+                name={item.name}
+                description={item.description}
+                more={item.more}
+                enabled={config[item.key]}
+                onChange={enabled => onChange?.(item.key, enabled)}
+                readonly={readonly || item.readonly}
+              />
+            ))}
+          </div>)}
+      </FeaturePanel>
+      {
+        showSetSerpAPIKeyModal && (
+          <AccountSetting activeTab="plugin" onCancel={async () => {
+            setShowSetAPIKeyModal(false)
+            await checkSerpApiKey()
+          }} />
+        )
+      }
+    </>
+  )
+}
+export default React.memo(Plugins)

+ 3 - 0
web/app/components/explore/universal-chat/config/plugins-config/item.module.css

@@ -0,0 +1,3 @@
+.shadow {
+  box-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.05);
+}

+ 43 - 0
web/app/components/explore/universal-chat/config/plugins-config/item.tsx

@@ -0,0 +1,43 @@
+'use client'
+import type { FC } from 'react'
+import React from 'react'
+import cn from 'classnames'
+import s from './item.module.css'
+import Switch from '@/app/components/base/switch'
+
+export type IItemProps = {
+  icon: React.ReactNode
+  name: string
+  description?: string
+  more?: React.ReactNode
+  enabled: boolean
+  onChange: (enabled: boolean) => void
+  readonly?: boolean
+}
+
+const Item: FC<IItemProps> = ({
+  icon,
+  name,
+  description,
+  more,
+  enabled,
+  onChange,
+  readonly,
+}) => {
+  return (
+    <div className={cn('bg-white rounded-xl border border-gray-200 overflow-hidden', s.shadow)}>
+      <div className='flex justify-between items-center min-h-[48px] px-2'>
+        <div className='flex items-center space-x-2'>
+          {icon}
+          <div className='leading-[18px]'>
+            <div className='text-[13px] font-medium text-gray-800'>{name}</div>
+            {description && <div className='text-xs leading-[18px] text-gray-500'>{description}</div>}
+          </div>
+        </div>
+        <Switch size='md' defaultValue={enabled} onChange={onChange} disabled={readonly} />
+      </div>
+      {more}
+    </div>
+  )
+}
+export default React.memo(Item)

+ 72 - 0
web/app/components/explore/universal-chat/hooks/use-conversation.ts

@@ -0,0 +1,72 @@
+import { useState } from 'react'
+import produce from 'immer'
+import { useGetState } from 'ahooks'
+import type { ConversationItem } from '@/models/share'
+
+const storageConversationIdKey = 'conversationIdInfo'
+
+type ConversationInfoType = Omit<ConversationItem, 'inputs' | 'id'>
+function useConversation() {
+  const [conversationList, setConversationList] = useState<ConversationItem[]>([])
+  const [pinnedConversationList, setPinnedConversationList] = useState<ConversationItem[]>([])
+  const [currConversationId, doSetCurrConversationId, getCurrConversationId] = useGetState<string>('-1')
+  // when set conversation id, we do not have set appId
+  const setCurrConversationId = (id: string, appId: string, isSetToLocalStroge = true, newConversationName = '') => {
+    doSetCurrConversationId(id)
+    if (isSetToLocalStroge && id !== '-1') {
+      // conversationIdInfo: {[appId1]: conversationId1, [appId2]: conversationId2}
+      const conversationIdInfo = globalThis.localStorage?.getItem(storageConversationIdKey) ? JSON.parse(globalThis.localStorage?.getItem(storageConversationIdKey) || '') : {}
+      conversationIdInfo[appId] = id
+      globalThis.localStorage?.setItem(storageConversationIdKey, JSON.stringify(conversationIdInfo))
+    }
+  }
+
+  const getConversationIdFromStorage = (appId: string) => {
+    const conversationIdInfo = globalThis.localStorage?.getItem(storageConversationIdKey) ? JSON.parse(globalThis.localStorage?.getItem(storageConversationIdKey) || '') : {}
+    const id = conversationIdInfo[appId]
+    return id
+  }
+
+  const isNewConversation = currConversationId === '-1'
+  // input can be updated by user
+  const [newConversationInputs, setNewConversationInputs] = useState<Record<string, any> | null>(null)
+  const resetNewConversationInputs = () => {
+    if (!newConversationInputs)
+      return
+    setNewConversationInputs(produce(newConversationInputs, (draft) => {
+      Object.keys(draft).forEach((key) => {
+        draft[key] = ''
+      })
+    }))
+  }
+  const [existConversationInputs, setExistConversationInputs] = useState<Record<string, any> | null>(null)
+  const currInputs = isNewConversation ? newConversationInputs : existConversationInputs
+  const setCurrInputs = isNewConversation ? setNewConversationInputs : setExistConversationInputs
+
+  // info is muted
+  const [newConversationInfo, setNewConversationInfo] = useState<ConversationInfoType | null>(null)
+  const [existConversationInfo, setExistConversationInfo] = useState<ConversationInfoType | null>(null)
+  const currConversationInfo = isNewConversation ? newConversationInfo : existConversationInfo
+
+  return {
+    conversationList,
+    setConversationList,
+    pinnedConversationList,
+    setPinnedConversationList,
+    currConversationId,
+    getCurrConversationId,
+    setCurrConversationId,
+    getConversationIdFromStorage,
+    isNewConversation,
+    currInputs,
+    newConversationInputs,
+    existConversationInputs,
+    resetNewConversationInputs,
+    setCurrInputs,
+    currConversationInfo,
+    setNewConversationInfo,
+    setExistConversationInfo,
+  }
+}
+
+export default useConversation

+ 725 - 0
web/app/components/explore/universal-chat/index.tsx

@@ -0,0 +1,725 @@
+/* eslint-disable react-hooks/exhaustive-deps */
+/* eslint-disable @typescript-eslint/no-use-before-define */
+'use client'
+import type { FC } from 'react'
+import React, { useEffect, useRef, useState } from 'react'
+import cn from 'classnames'
+import { useTranslation } from 'react-i18next'
+import { useContext } from 'use-context-selector'
+import produce from 'immer'
+import { useBoolean, useGetState } from 'ahooks'
+import AppUnavailable from '../../base/app-unavailable'
+import useConversation from './hooks/use-conversation'
+import s from './style.module.css'
+import Init from './init'
+import { ToastContext } from '@/app/components/base/toast'
+import Sidebar from '@/app/components/share/chat/sidebar'
+import {
+  delConversation,
+  fetchAppParams,
+  fetchChatList,
+  fetchConversations,
+  fetchSuggestedQuestions,
+  pinConversation,
+  sendChatMessage,
+  stopChatMessageResponding,
+  unpinConversation,
+  updateFeedback,
+} from '@/service/universal-chat'
+import type { ConversationItem, SiteInfo } from '@/models/share'
+import type { PromptConfig, SuggestedQuestionsAfterAnswerConfig } from '@/models/debug'
+import type { Feedbacktype, IChatItem } from '@/app/components/app/chat/type'
+import Chat from '@/app/components/app/chat'
+import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
+import Loading from '@/app/components/base/loading'
+import { replaceStringWithValues } from '@/app/components/app/configuration/prompt-value-panel'
+import { userInputsFormToPromptVariables } from '@/utils/model-config'
+import Confirm from '@/app/components/base/confirm'
+import type { DataSet } from '@/models/datasets'
+import ConfigSummary from '@/app/components/explore/universal-chat/config-view/summary'
+import { fetchDatasets } from '@/service/datasets'
+import ItemOperation from '@/app/components/explore/item-operation'
+
+const APP_ID = 'universal-chat'
+const DEFAULT_MODEL_ID = 'gpt-3.5-turbo' // gpt-4, claude-2
+const DEFAULT_PLUGIN = {
+  google_search: false,
+  web_reader: true,
+  wikipedia: true,
+}
+export type IMainProps = {}
+
+const Main: FC<IMainProps> = () => {
+  const { t } = useTranslation()
+  const media = useBreakpoints()
+  const isMobile = media === MediaType.mobile
+
+  /*
+  * app info
+  */
+  const [appUnavailable, setAppUnavailable] = useState<boolean>(false)
+  const [isUnknwonReason, setIsUnknwonReason] = useState<boolean>(false)
+  const siteInfo: SiteInfo = (
+    {
+      title: 'universal Chatbot',
+      icon: '',
+      icon_background: '',
+      description: '',
+      default_language: 'en', // TODO
+      prompt_public: true,
+    }
+  )
+  const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null)
+  const [inited, setInited] = useState<boolean>(false)
+  // in mobile, show sidebar by click button
+  const [isShowSidebar, { setTrue: showSidebar, setFalse: hideSidebar }] = useBoolean(false)
+  /*
+  * conversation info
+  */
+  const [allConversationList, setAllConversationList] = useState<ConversationItem[]>([])
+  const [isClearConversationList, { setTrue: clearConversationListTrue, setFalse: clearConversationListFalse }] = useBoolean(false)
+  const [isClearPinnedConversationList, { setTrue: clearPinnedConversationListTrue, setFalse: clearPinnedConversationListFalse }] = useBoolean(false)
+  const {
+    conversationList,
+    setConversationList,
+    pinnedConversationList,
+    setPinnedConversationList,
+    currConversationId,
+    getCurrConversationId,
+    setCurrConversationId,
+    getConversationIdFromStorage,
+    isNewConversation,
+    currConversationInfo,
+    currInputs,
+    newConversationInputs,
+    // existConversationInputs,
+    resetNewConversationInputs,
+    setCurrInputs,
+    setNewConversationInfo,
+    setExistConversationInfo,
+  } = useConversation()
+  const [hasMore, setHasMore] = useState<boolean>(true)
+  const [hasPinnedMore, setHasPinnedMore] = useState<boolean>(true)
+  const onMoreLoaded = ({ data: conversations, has_more }: any) => {
+    setHasMore(has_more)
+    if (isClearConversationList) {
+      setConversationList(conversations)
+      clearConversationListFalse()
+    }
+    else {
+      setConversationList([...conversationList, ...conversations])
+    }
+  }
+  const onPinnedMoreLoaded = ({ data: conversations, has_more }: any) => {
+    setHasPinnedMore(has_more)
+    if (isClearPinnedConversationList) {
+      setPinnedConversationList(conversations)
+      clearPinnedConversationListFalse()
+    }
+    else {
+      setPinnedConversationList([...pinnedConversationList, ...conversations])
+    }
+  }
+  const [controlUpdateConversationList, setControlUpdateConversationList] = useState(0)
+  const noticeUpdateList = () => {
+    setHasMore(true)
+    clearConversationListTrue()
+
+    setHasPinnedMore(true)
+    clearPinnedConversationListTrue()
+
+    setControlUpdateConversationList(Date.now())
+  }
+  const handlePin = async (id: string) => {
+    await pinConversation(id)
+    setControlItemOpHide(Date.now())
+    notify({ type: 'success', message: t('common.api.success') })
+    noticeUpdateList()
+  }
+
+  const handleUnpin = async (id: string) => {
+    await unpinConversation(id)
+    setControlItemOpHide(Date.now())
+    notify({ type: 'success', message: t('common.api.success') })
+    noticeUpdateList()
+  }
+  const [isShowConfirm, { setTrue: showConfirm, setFalse: hideConfirm }] = useBoolean(false)
+  const [toDeleteConversationId, setToDeleteConversationId] = useState('')
+  const handleDelete = (id: string) => {
+    setToDeleteConversationId(id)
+    hideSidebar() // mobile
+    showConfirm()
+  }
+
+  const didDelete = async () => {
+    await delConversation(toDeleteConversationId)
+    setControlItemOpHide(Date.now())
+    notify({ type: 'success', message: t('common.api.success') })
+    hideConfirm()
+    if (currConversationId === toDeleteConversationId)
+      handleConversationIdChange('-1')
+
+    noticeUpdateList()
+  }
+
+  const [suggestedQuestionsAfterAnswerConfig, setSuggestedQuestionsAfterAnswerConfig] = useState<SuggestedQuestionsAfterAnswerConfig | null>(null)
+  const [speechToTextConfig, setSpeechToTextConfig] = useState<SuggestedQuestionsAfterAnswerConfig | null>(null)
+
+  const [conversationIdChangeBecauseOfNew, setConversationIdChangeBecauseOfNew, getConversationIdChangeBecauseOfNew] = useGetState(false)
+
+  const conversationName = currConversationInfo?.name || t('share.chat.newChatDefaultName') as string
+  const conversationIntroduction = currConversationInfo?.introduction || ''
+
+  const handleConversationSwitch = async () => {
+    if (!inited)
+      return
+
+    // update inputs of current conversation
+    let notSyncToStateIntroduction = ''
+    let notSyncToStateInputs: Record<string, any> | undefined | null = {}
+    // debugger
+    if (!isNewConversation) {
+      const item = allConversationList.find(item => item.id === currConversationId) as any
+      notSyncToStateInputs = item?.inputs || {}
+      // setCurrInputs(notSyncToStateInputs)
+      notSyncToStateIntroduction = item?.introduction || ''
+      setExistConversationInfo({
+        name: item?.name || '',
+        introduction: notSyncToStateIntroduction,
+      })
+      const modelConfig = item?.model_config
+      if (modelConfig) {
+        setModeId(modelConfig.model_id)
+        const pluginConfig: Record<string, boolean> = {}
+        const datasetIds: string[] = []
+        modelConfig.agent_mode.tools.forEach((item: any) => {
+          const pluginName = Object.keys(item)[0]
+          if (pluginName === 'dataset')
+            datasetIds.push(item.dataset.id)
+          else
+            pluginConfig[pluginName] = item[pluginName].enabled
+        })
+        setPlugins(pluginConfig)
+        if (datasetIds.length > 0) {
+          const { data } = await fetchDatasets({ url: '/datasets', params: { page: 1, ids: datasetIds } })
+          setDateSets(data)
+        }
+        else {
+          setDateSets([])
+        }
+      }
+      else {
+        configSetDefaultValue()
+      }
+    }
+    else {
+      configSetDefaultValue()
+      notSyncToStateInputs = newConversationInputs
+      setCurrInputs(notSyncToStateInputs)
+    }
+
+    // update chat list of current conversation
+    if (!isNewConversation && !conversationIdChangeBecauseOfNew) {
+      fetchChatList(currConversationId).then((res: any) => {
+        const { data } = res
+        const newChatList: IChatItem[] = generateNewChatListWithOpenstatement(notSyncToStateIntroduction, notSyncToStateInputs)
+
+        data.forEach((item: any) => {
+          newChatList.push({
+            id: `question-${item.id}`,
+            content: item.query,
+            isAnswer: false,
+          })
+          newChatList.push({
+            ...item,
+            id: item.id,
+            content: item.answer,
+            feedback: item.feedback,
+            isAnswer: true,
+          })
+        })
+        setChatList(newChatList)
+        setErrorHappened(false)
+      })
+    }
+
+    if (isNewConversation) {
+      setChatList(generateNewChatListWithOpenstatement())
+      setErrorHappened(false)
+    }
+
+    setControlFocus(Date.now())
+  }
+
+  useEffect(() => {
+    handleConversationSwitch()
+  }, [currConversationId, inited])
+
+  const handleConversationIdChange = (id: string) => {
+    if (id === '-1') {
+      createNewChat()
+      setConversationIdChangeBecauseOfNew(true)
+    }
+    else {
+      setConversationIdChangeBecauseOfNew(false)
+    }
+    // trigger handleConversationSwitch
+    setCurrConversationId(id, APP_ID)
+    setIsShowSuggestion(false)
+    hideSidebar()
+  }
+
+  /*
+  * chat info. chat is under conversation.
+  */
+  const [chatList, setChatList, getChatList] = useGetState<IChatItem[]>([])
+  const chatListDomRef = useRef<HTMLDivElement>(null)
+  useEffect(() => {
+    // scroll to bottom
+    if (chatListDomRef.current)
+      chatListDomRef.current.scrollTop = chatListDomRef.current.scrollHeight
+  }, [chatList, currConversationId])
+
+  // user can not edit inputs if user had send message
+  const createNewChat = async () => {
+    // if new chat is already exist, do not create new chat
+    abortController?.abort()
+    setResponsingFalse()
+    if (conversationList.some(item => item.id === '-1'))
+      return
+
+    setConversationList(produce(conversationList, (draft) => {
+      draft.unshift({
+        id: '-1',
+        name: t('share.chat.newChatDefaultName'),
+        inputs: newConversationInputs,
+        introduction: conversationIntroduction,
+      })
+    }))
+    configSetDefaultValue()
+  }
+
+  // sometime introduction is not applied to state
+  const generateNewChatListWithOpenstatement = (introduction?: string, inputs?: Record<string, any> | null) => {
+    let caculatedIntroduction = introduction || conversationIntroduction || ''
+    const caculatedPromptVariables = inputs || currInputs || null
+    if (caculatedIntroduction && caculatedPromptVariables)
+      caculatedIntroduction = replaceStringWithValues(caculatedIntroduction, promptConfig?.prompt_variables || [], caculatedPromptVariables)
+
+    const openstatement = {
+      id: `${Date.now()}`,
+      content: caculatedIntroduction,
+      isAnswer: true,
+      feedbackDisabled: true,
+      isOpeningStatement: true,
+    }
+    if (caculatedIntroduction)
+      return [openstatement]
+
+    return []
+  }
+
+  const fetchAllConversations = () => {
+    return fetchConversations(undefined, undefined, 100)
+  }
+
+  const fetchInitData = async () => {
+    return Promise.all([fetchAllConversations(), fetchAppParams()])
+  }
+
+  // init
+  useEffect(() => {
+    (async () => {
+      try {
+        const [conversationData, appParams]: any = await fetchInitData()
+        const prompt_template = ''
+        // handle current conversation id
+        const { data: allConversations } = conversationData as { data: ConversationItem[]; has_more: boolean }
+        const _conversationId = getConversationIdFromStorage(APP_ID)
+        const isNotNewConversation = allConversations.some(item => item.id === _conversationId)
+        setAllConversationList(allConversations)
+        // fetch new conversation info
+        const { user_input_form, opening_statement: introduction, suggested_questions_after_answer, speech_to_text }: any = appParams
+        const prompt_variables = userInputsFormToPromptVariables(user_input_form)
+
+        setNewConversationInfo({
+          name: t('share.chat.newChatDefaultName'),
+          introduction,
+        })
+        setPromptConfig({
+          prompt_template,
+          prompt_variables,
+        } as PromptConfig)
+        setSuggestedQuestionsAfterAnswerConfig(suggested_questions_after_answer)
+        setSpeechToTextConfig(speech_to_text)
+
+        if (isNotNewConversation)
+          setCurrConversationId(_conversationId, APP_ID, false)
+
+        setInited(true)
+      }
+      catch (e: any) {
+        if (e.status === 404) {
+          setAppUnavailable(true)
+        }
+        else {
+          setIsUnknwonReason(true)
+          setAppUnavailable(true)
+        }
+      }
+    })()
+  }, [])
+
+  const [isResponsing, { setTrue: setResponsingTrue, setFalse: setResponsingFalse }] = useBoolean(false)
+  const [abortController, setAbortController] = useState<AbortController | null>(null)
+  const { notify } = useContext(ToastContext)
+  const logError = (message: string) => {
+    notify({ type: 'error', message })
+  }
+
+  const checkCanSend = () => {
+    if (currConversationId !== '-1')
+      return true
+
+    const prompt_variables = promptConfig?.prompt_variables
+    const inputs = currInputs
+    if (!inputs || !prompt_variables || prompt_variables?.length === 0)
+      return true
+
+    let hasEmptyInput = false
+    const requiredVars = prompt_variables?.filter(({ key, name, required }) => {
+      const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
+      return res
+    }) || [] // compatible with old version
+    requiredVars.forEach(({ key }) => {
+      if (hasEmptyInput)
+        return
+
+      if (!inputs?.[key])
+        hasEmptyInput = true
+    })
+
+    if (hasEmptyInput) {
+      logError(t('appDebug.errorMessage.valueOfVarRequired'))
+      return false
+    }
+    return !hasEmptyInput
+  }
+
+  const [controlFocus, setControlFocus] = useState(0)
+  const [isShowSuggestion, setIsShowSuggestion] = useState(false)
+  const doShowSuggestion = isShowSuggestion && !isResponsing
+  const [suggestQuestions, setSuggestQuestions] = useState<string[]>([])
+  const [messageTaskId, setMessageTaskId] = useState('')
+  const [hasStopResponded, setHasStopResponded, getHasStopResponded] = useGetState(false)
+  const [errorHappened, setErrorHappened] = useState(false)
+  const [isResponsingConIsCurrCon, setIsResponsingConCurrCon, getIsResponsingConIsCurrCon] = useGetState(true)
+  const handleSend = async (message: string) => {
+    if (isResponsing) {
+      notify({ type: 'info', message: t('appDebug.errorMessage.waitForResponse') })
+      return
+    }
+    const formattedPlugins = Object.keys(plugins).map(key => ({
+      [key]: {
+        enabled: plugins[key],
+      },
+    }))
+    const formattedDataSets = dataSets.map(({ id }) => {
+      return {
+        dataset: {
+          enabled: true,
+          id,
+        },
+      }
+    })
+    const data = {
+      query: message,
+      conversation_id: isNewConversation ? null : currConversationId,
+      model: modelId,
+      tools: [...formattedPlugins, ...formattedDataSets],
+    }
+
+    // qustion
+    const questionId = `question-${Date.now()}`
+    const questionItem = {
+      id: questionId,
+      content: message,
+      agent_thoughts: [],
+      isAnswer: false,
+    }
+
+    const placeholderAnswerId = `answer-placeholder-${Date.now()}`
+    const placeholderAnswerItem = {
+      id: placeholderAnswerId,
+      content: '',
+      isAnswer: true,
+    }
+
+    const newList = [...getChatList(), questionItem, placeholderAnswerItem]
+    setChatList(newList)
+
+    // answer
+    const responseItem: IChatItem = {
+      id: `${Date.now()}`,
+      content: '',
+      agent_thoughts: [],
+      isAnswer: true,
+    }
+
+    const prevTempNewConversationId = getCurrConversationId() || '-1'
+    let tempNewConversationId = prevTempNewConversationId
+
+    setHasStopResponded(false)
+    setResponsingTrue()
+    setErrorHappened(false)
+    setIsShowSuggestion(false)
+    setIsResponsingConCurrCon(true)
+
+    sendChatMessage(data, {
+      getAbortController: (abortController) => {
+        setAbortController(abortController)
+      },
+      onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: any) => {
+        responseItem.content = responseItem.content + message
+        responseItem.id = messageId
+        if (isFirstMessage && newConversationId)
+          tempNewConversationId = newConversationId
+
+        setMessageTaskId(taskId)
+        // has switched to other conversation
+        if (prevTempNewConversationId !== getCurrConversationId()) {
+          setIsResponsingConCurrCon(false)
+          return
+        }
+
+        // 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 } as any)
+
+            draft.push({ ...responseItem })
+          })
+
+        setChatList(newListWithAnswer)
+      },
+      async onCompleted(hasError?: boolean) {
+        if (hasError) {
+          setResponsingFalse()
+          return
+        }
+
+        if (getConversationIdChangeBecauseOfNew()) {
+          const { data: allConversations }: any = await fetchAllConversations()
+          setAllConversationList(allConversations)
+          noticeUpdateList()
+        }
+        setConversationIdChangeBecauseOfNew(false)
+        resetNewConversationInputs()
+        setCurrConversationId(tempNewConversationId, APP_ID, true)
+        if (getIsResponsingConIsCurrCon() && suggestedQuestionsAfterAnswerConfig?.enabled && !getHasStopResponded()) {
+          const { data }: any = await fetchSuggestedQuestions(responseItem.id)
+          setSuggestQuestions(data)
+          setIsShowSuggestion(true)
+        }
+        setResponsingFalse()
+      },
+      onThought(thought) {
+        // thought finished then start to return message. Warning: use push agent_thoughts.push would caused problem when the thought is more then 2
+        responseItem.id = thought.message_id;
+        (responseItem as any).agent_thoughts = [...(responseItem as any).agent_thoughts, thought] // .push(thought)
+        // has switched to other conversation
+
+        if (prevTempNewConversationId !== getCurrConversationId()) {
+          setIsResponsingConCurrCon(false)
+          return
+        }
+        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)
+      },
+      onError() {
+        setErrorHappened(true)
+        // role back placeholder answer
+        setChatList(produce(getChatList(), (draft) => {
+          draft.splice(draft.findIndex(item => item.id === placeholderAnswerId), 1)
+        }))
+        setResponsingFalse()
+      },
+    })
+  }
+
+  const handleFeedback = async (messageId: string, feedback: Feedbacktype) => {
+    await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating } })
+    const newChatList = chatList.map((item) => {
+      if (item.id === messageId) {
+        return {
+          ...item,
+          feedback,
+        }
+      }
+      return item
+    })
+    setChatList(newChatList)
+    notify({ type: 'success', message: t('common.api.success') })
+  }
+
+  const renderSidebar = () => {
+    if (!APP_ID || !promptConfig)
+      return null
+    return (
+      <Sidebar
+        list={conversationList}
+        isClearConversationList={isClearConversationList}
+        pinnedList={pinnedConversationList}
+        isClearPinnedConversationList={isClearPinnedConversationList}
+        onMoreLoaded={onMoreLoaded}
+        onPinnedMoreLoaded={onPinnedMoreLoaded}
+        isNoMore={!hasMore}
+        isPinnedNoMore={!hasPinnedMore}
+        onCurrentIdChange={handleConversationIdChange}
+        currentId={currConversationId}
+        copyRight={''}
+        isInstalledApp={false}
+        isUniversalChat
+        installedAppId={''}
+        siteInfo={siteInfo}
+        onPin={handlePin}
+        onUnpin={handleUnpin}
+        controlUpdateList={controlUpdateConversationList}
+        onDelete={handleDelete}
+      />
+    )
+  }
+
+  const [modelId, setModeId] = useState(DEFAULT_MODEL_ID)
+  // const currModel = MODEL_LIST.find(item => item.id === modelId)
+
+  const [plugins, setPlugins] = useState<Record<string, boolean>>(DEFAULT_PLUGIN)
+  const handlePluginsChange = (key: string, value: boolean) => {
+    setPlugins({
+      ...plugins,
+      [key]: value,
+    })
+  }
+  const [dataSets, setDateSets] = useState<DataSet[]>([])
+  const configSetDefaultValue = () => {
+    setModeId(DEFAULT_MODEL_ID)
+    setPlugins(DEFAULT_PLUGIN)
+    setDateSets([])
+  }
+  const isCurrConversationPinned = !!pinnedConversationList.find(item => item.id === currConversationId)
+  const [controlItemOpHide, setControlItemOpHide] = useState(0)
+  if (appUnavailable)
+    return <AppUnavailable isUnknwonReason={isUnknwonReason} />
+
+  if (!promptConfig)
+    return <Loading type='app' />
+
+  return (
+    <div className='bg-gray-100'>
+      <div
+        className={cn(
+          'flex rounded-t-2xl bg-white overflow-hidden rounded-b-2xl',
+        )}
+        style={{
+          boxShadow: '0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03)',
+        }}
+      >
+        {/* sidebar */}
+        {!isMobile && renderSidebar()}
+        {isMobile && isShowSidebar && (
+          <div className='fixed inset-0 z-50'
+            style={{ backgroundColor: 'rgba(35, 56, 118, 0.2)' }}
+            onClick={hideSidebar}
+          >
+            <div className='inline-block' onClick={e => e.stopPropagation()}>
+              {renderSidebar()}
+            </div>
+          </div>
+        )}
+        {/* main */}
+        <div className={cn(
+          s.installedApp,
+          'flex-grow flex flex-col overflow-y-auto',
+        )
+        }>
+          {(!isNewConversation || isResponsing || errorHappened) && (
+            <div className='mb-5 antialiased font-sans shrink-0 relative mobile:min-h-[48px] tablet:min-h-[64px]'>
+              <div className='absolute z-10 top-0 left-0 right-0 flex items-center justify-between border-b border-gray-100 mobile:h-12 tablet:h-16 px-8 bg-white'>
+                <div className='text-gray-900'>{conversationName}</div>
+                <div className='flex items-center shrink-0 ml-2 space-x-2'>
+                  <ConfigSummary
+                    modelId={modelId}
+                    plugins={plugins}
+                    dataSets={dataSets}
+                  />
+                  <div className={cn('flex w-8 h-8 justify-center items-center shrink-0 rounded-lg border border-gray-200')} onClick={e => e.stopPropagation()}>
+                    <ItemOperation
+                      key={controlItemOpHide}
+                      className='!w-8 !h-8'
+                      isPinned={isCurrConversationPinned}
+                      togglePin={() => isCurrConversationPinned ? handleUnpin(currConversationId) : handlePin(currConversationId)}
+                      isShowDelete
+                      onDelete={() => handleDelete(currConversationId)}
+                    />
+                  </div>
+                </div>
+              </div>
+            </div>
+          )}
+          <div className={cn(doShowSuggestion ? 'pb-[140px]' : (isResponsing ? 'pb-[113px]' : 'pb-[76px]'), 'relative grow h-[200px] pc:w-[794px] max-w-full mobile:w-full mx-auto  mb-3.5 overflow-hidden')}>
+            <div className={cn('pc:w-[794px] max-w-full mobile:w-full mx-auto h-full overflow-y-auto')} ref={chatListDomRef}>
+              <Chat
+                isShowConfigElem={isNewConversation && chatList.length === 0}
+                configElem={<Init
+                  modelId={modelId}
+                  onModelChange={setModeId}
+                  plugins={plugins}
+                  onPluginChange={handlePluginsChange}
+                  dataSets={dataSets}
+                  onDataSetsChange={setDateSets}
+                />}
+                chatList={chatList}
+                onSend={handleSend}
+                isHideFeedbackEdit
+                onFeedback={handleFeedback}
+                isResponsing={isResponsing}
+                canStopResponsing={!!messageTaskId && isResponsingConIsCurrCon}
+                abortResponsing={async () => {
+                  await stopChatMessageResponding(messageTaskId)
+                  setHasStopResponded(true)
+                  setResponsingFalse()
+                }}
+                checkCanSend={checkCanSend}
+                controlFocus={controlFocus}
+                isShowSuggestion={doShowSuggestion}
+                suggestionList={suggestQuestions}
+                isShowSpeechToText={speechToTextConfig?.enabled}
+                dataSets={dataSets}
+              />
+            </div>
+          </div>
+
+          {isShowConfirm && (
+            <Confirm
+              title={t('share.chat.deleteConversation.title')}
+              content={t('share.chat.deleteConversation.content')}
+              isShow={isShowConfirm}
+              onClose={hideConfirm}
+              onConfirm={didDelete}
+              onCancel={hideConfirm}
+            />
+          )}
+        </div>
+      </div>
+    </div>
+  )
+}
+export default React.memo(Main)

+ 43 - 0
web/app/components/explore/universal-chat/init/index.tsx

@@ -0,0 +1,43 @@
+'use client'
+import type { FC } from 'react'
+import React from 'react'
+import { useTranslation } from 'react-i18next'
+import cn from 'classnames'
+import type { IConfigProps } from '../config'
+import Config from '../config'
+import s from './style.module.css'
+
+const Line = (
+  <svg width="720" height="1" viewBox="0 0 720 1" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <line y1="0.5" x2="720" y2="0.5" stroke="url(#paint0_linear_6845_53470)"/>
+    <defs>
+      <linearGradient id="paint0_linear_6845_53470" x1="0" y1="1" x2="720" y2="1" gradientUnits="userSpaceOnUse">
+        <stop stopColor="#F2F4F7" stopOpacity="0"/>
+        <stop offset="0.491667" stopColor="#F2F4F7"/>
+        <stop offset="1" stopColor="#F2F4F7" stopOpacity="0"/>
+      </linearGradient>
+    </defs>
+  </svg>
+)
+
+const Init: FC<IConfigProps> = ({
+  ...configProps
+}) => {
+  const { t } = useTranslation()
+
+  return (
+    <div className='h-full flex items-center'>
+      <div>
+        <div className='w-[480px] mx-auto text-center'>
+          <div className={cn(s.textGradient, 'mb-2 leading-[32px] font-semibold text-[24px]')}>{t('explore.universalChat.welcome')}</div>
+          <div className='mb-2 font-normal text-sm text-gray-500'>{t('explore.universalChat.welcomeDescribe')}</div>
+        </div>
+        <div className='flex mb-2 mx-auto h-8 items-center'>
+          {Line}
+        </div>
+        <Config className='w-[480px] mx-auto' {...configProps} />
+      </div>
+    </div>
+  )
+}
+export default React.memo(Init)

+ 9 - 0
web/app/components/explore/universal-chat/init/style.module.css

@@ -0,0 +1,9 @@
+.textGradient {
+  background: linear-gradient(to right, rgba(16, 74, 225, 1) 0, rgba(0, 152, 238, 1) 100%);
+  -webkit-background-clip: text;
+  -webkit-text-fill-color: transparent;
+  background-clip: text;
+  text-fill-color: transparent;
+}
+
+

+ 3 - 0
web/app/components/explore/universal-chat/style.module.css

@@ -0,0 +1,3 @@
+.installedApp {
+  height: calc(100vh - 74px);
+}

+ 10 - 2
web/app/components/header/account-setting/index.tsx

@@ -9,11 +9,12 @@ import MembersPage from './members-page'
 import IntegrationsPage from './Integrations-page'
 import LanguagePage from './language-page'
 import ProviderPage from './provider-page'
+import PluginPage from './plugin-page'
 import DataSourcePage from './data-source-page'
 import s from './index.module.css'
 import Modal from '@/app/components/base/modal'
-import { Database03 } from '@/app/components/base/icons/src/vender/line/development'
-import { Database03 as Database03Solid } from '@/app/components/base/icons/src/vender/solid/development'
+import { Database03, PuzzlePiece01 } from '@/app/components/base/icons/src/vender/line/development'
+import { Database03 as Database03Solid, PuzzlePiece01 as PuzzlePiece01Solid } from '@/app/components/base/icons/src/vender/solid/development'
 
 const iconClassName = `
   w-4 h-4 ml-3 mr-2
@@ -80,6 +81,12 @@ export default function AccountSetting({
           icon: <Database03 className={iconClassName} />,
           activeIcon: <Database03Solid className={iconClassName} />,
         },
+        {
+          key: 'plugin',
+          name: t('common.settings.plugin'),
+          icon: <PuzzlePiece01 className={iconClassName} />,
+          activeIcon: <PuzzlePiece01Solid className={iconClassName} />,
+        },
       ],
     },
   ]
@@ -148,6 +155,7 @@ export default function AccountSetting({
             {activeMenu === 'language' && <LanguagePage />}
             {activeMenu === 'provider' && <ProviderPage />}
             {activeMenu === 'data-source' && <DataSourcePage />}
+            {activeMenu === 'plugin' && <PluginPage />}
           </div>
         </div>
       </div>

+ 77 - 0
web/app/components/header/account-setting/key-validator/KeyInput.tsx

@@ -0,0 +1,77 @@
+import type { ChangeEvent } from 'react'
+import {
+  ValidatedErrorIcon,
+  ValidatedErrorMessage,
+  ValidatedSuccessIcon,
+  ValidatingTip,
+} from './ValidateStatus'
+import { ValidatedStatus } from './declarations'
+import type { ValidatedStatusState } from './declarations'
+
+type KeyInputProps = {
+  value?: string
+  name: string
+  placeholder: string
+  className?: string
+  onChange: (v: string) => void
+  onFocus?: () => void
+  validating: boolean
+  validatedStatusState: ValidatedStatusState
+}
+
+const KeyInput = ({
+  value,
+  name,
+  placeholder,
+  className,
+  onChange,
+  onFocus,
+  validating,
+  validatedStatusState,
+}: KeyInputProps) => {
+  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
+    const inputValue = e.target.value
+    onChange(inputValue)
+  }
+
+  const getValidatedIcon = () => {
+    if (validatedStatusState.status === ValidatedStatus.Error || validatedStatusState.status === ValidatedStatus.Exceed)
+      return <ValidatedErrorIcon />
+
+    if (validatedStatusState.status === ValidatedStatus.Success)
+      return <ValidatedSuccessIcon />
+  }
+  const getValidatedTip = () => {
+    if (validating)
+      return <ValidatingTip />
+
+    if (validatedStatusState.status === ValidatedStatus.Error)
+      return <ValidatedErrorMessage errorMessage={validatedStatusState.message ?? ''} />
+  }
+
+  return (
+    <div className={className}>
+      <div className="mb-2 text-[13px] font-medium text-gray-800">{name}</div>
+      <div className='
+        flex items-center px-3 bg-white rounded-lg
+        shadow-[0_1px_2px_rgba(16,24,40,0.05)]
+      '>
+        <input
+          className='
+            w-full py-[9px] mr-2
+            text-xs font-medium text-gray-700 leading-[18px]
+            appearance-none outline-none bg-transparent
+          '
+          value={value}
+          placeholder={placeholder}
+          onChange={handleChange}
+          onFocus={onFocus}
+        />
+        {getValidatedIcon()}
+      </div>
+      {getValidatedTip()}
+    </div>
+  )
+}
+
+export default KeyInput

+ 85 - 0
web/app/components/header/account-setting/key-validator/Operate.tsx

@@ -0,0 +1,85 @@
+import { useTranslation } from 'react-i18next'
+import Indicator from '../../indicator'
+import type { Status } from './declarations'
+
+type OperateProps = {
+  isOpen: boolean
+  status: Status
+  onCancel: () => void
+  onSave: () => void
+  onAdd: () => void
+  onEdit: () => void
+}
+
+const Operate = ({
+  isOpen,
+  status,
+  onCancel,
+  onSave,
+  onAdd,
+  onEdit,
+}: OperateProps) => {
+  const { t } = useTranslation()
+
+  if (isOpen) {
+    return (
+      <div className='flex items-center'>
+        <div className='
+          flex items-center
+          mr-[5px] px-3 h-7 rounded-md cursor-pointer
+          text-xs font-medium text-gray-700
+        ' onClick={onCancel} >
+          {t('common.operation.cancel')}
+        </div>
+        <div className='
+          flex items-center
+          px-3 h-7 rounded-md cursor-pointer bg-primary-700
+          text-xs font-medium text-white
+        ' onClick={onSave}>
+          {t('common.operation.save')}
+        </div>
+      </div>
+    )
+  }
+
+  if (status === 'add') {
+    return (
+      <div className='
+        px-3 h-[28px] bg-white border border-gray-200 rounded-md cursor-pointer
+        text-xs font-medium text-gray-700 flex items-center
+      ' onClick={onAdd}>
+        {t('common.provider.addKey')}
+      </div>
+    )
+  }
+
+  if (status === 'fail' || status === 'success') {
+    return (
+      <div className='flex items-center'>
+        {
+          status === 'fail' && (
+            <div className='flex items-center mr-4'>
+              <div className='text-xs text-[#D92D20]'>{t('common.provider.invalidApiKey')}</div>
+              <Indicator color='red' className='ml-2' />
+            </div>
+          )
+        }
+        {
+          status === 'success' && (
+            <Indicator color='green' className='mr-4' />
+          )
+        }
+        <div className='
+          px-3 h-[28px] bg-white border border-gray-200 rounded-md cursor-pointer
+          text-xs font-medium text-gray-700 flex items-center
+        ' onClick={onEdit}>
+          {t('common.provider.editKey')}
+        </div>
+      </div>
+    )
+  }
+
+  return null
+}
+
+export default Operate

+ 30 - 0
web/app/components/header/account-setting/key-validator/ValidateStatus.tsx

@@ -0,0 +1,30 @@
+import { useTranslation } from 'react-i18next'
+import { AlertCircle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
+import { CheckCircle } from '@/app/components/base/icons/src/vender/solid/general'
+
+export const ValidatedErrorIcon = () => {
+  return <AlertCircle className='w-4 h-4 text-[#D92D20]' />
+}
+
+export const ValidatedSuccessIcon = () => {
+  return <CheckCircle className='w-4 h-4 text-[#039855]' />
+}
+
+export const ValidatingTip = () => {
+  const { t } = useTranslation()
+  return (
+    <div className={'mt-2 text-primary-600 text-xs font-normal'}>
+      {t('common.provider.validating')}
+    </div>
+  )
+}
+
+export const ValidatedErrorMessage = ({ errorMessage }: { errorMessage: string }) => {
+  const { t } = useTranslation()
+
+  return (
+    <div className={'mt-2 text-[#D92D20] text-xs font-normal'}>
+      {t('common.provider.validatedError')}{errorMessage}
+    </div>
+  )
+}

+ 43 - 0
web/app/components/header/account-setting/key-validator/declarations.ts

@@ -0,0 +1,43 @@
+import type { Dispatch, SetStateAction } from 'react'
+
+export enum ValidatedStatus {
+  Success = 'success',
+  Error = 'error',
+  Exceed = 'exceed',
+}
+
+export type ValidatedStatusState = {
+  status?: ValidatedStatus
+  message?: string
+}
+
+export type Status = 'add' | 'fail' | 'success'
+
+export type ValidateValue = Record<string, string | undefined>
+
+export type ValidateCallback = {
+  before: (v?: ValidateValue) => boolean | undefined
+  run?: (v?: ValidateValue) => Promise<ValidatedStatusState>
+}
+
+export type Form = {
+  key: string
+  title: string
+  placeholder: string
+  value?: string
+  validate?: ValidateCallback
+  handleFocus?: (v: ValidateValue, dispatch: Dispatch<SetStateAction<ValidateValue>>) => void
+}
+
+export type KeyFrom = {
+  text: string
+  link: string
+}
+
+export type KeyValidatorProps = {
+  type: string
+  title: React.ReactNode
+  status: Status
+  forms: Form[]
+  keyFrom: KeyFrom
+}

+ 32 - 0
web/app/components/header/account-setting/key-validator/hooks.ts

@@ -0,0 +1,32 @@
+import { useState } from 'react'
+import { useDebounceFn } from 'ahooks'
+import type { DebouncedFunc } from 'lodash-es'
+import { ValidatedStatus } from './declarations'
+import type { ValidateCallback, ValidateValue, ValidatedStatusState } from './declarations'
+
+export const useValidate: (value: ValidateValue) => [DebouncedFunc<(validateCallback: ValidateCallback) => Promise<void>>, boolean, ValidatedStatusState] = (value) => {
+  const [validating, setValidating] = useState(false)
+  const [validatedStatus, setValidatedStatus] = useState<ValidatedStatusState>({})
+
+  const { run } = useDebounceFn(async (validateCallback: ValidateCallback) => {
+    if (!validateCallback.before(value)) {
+      setValidating(false)
+      setValidatedStatus({})
+      return
+    }
+
+    setValidating(true)
+
+    if (validateCallback.run) {
+      const res = await validateCallback?.run(value)
+      setValidatedStatus(
+        res.status === 'success'
+          ? { status: ValidatedStatus.Success }
+          : { status: ValidatedStatus.Error, message: res.message })
+
+      setValidating(false)
+    }
+  }, { wait: 500 })
+
+  return [run, validating, validatedStatus]
+}

+ 119 - 0
web/app/components/header/account-setting/key-validator/index.tsx

@@ -0,0 +1,119 @@
+import { useState } from 'react'
+import Operate from './Operate'
+import KeyInput from './KeyInput'
+import { useValidate } from './hooks'
+import type { Form, KeyFrom, Status, ValidateValue } from './declarations'
+import { useEventEmitterContextContext } from '@/context/event-emitter'
+import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
+
+export type KeyValidatorProps = {
+  type: string
+  title: React.ReactNode
+  status: Status
+  forms: Form[]
+  keyFrom: KeyFrom
+  onSave: (v: ValidateValue) => Promise<boolean | undefined>
+}
+
+const KeyValidator = ({
+  type,
+  title,
+  status,
+  forms,
+  keyFrom,
+  onSave,
+}: KeyValidatorProps) => {
+  const triggerKey = `plugins/${type}`
+  const { eventEmitter } = useEventEmitterContextContext()
+  const [isOpen, setIsOpen] = useState(false)
+  const prevValue = forms.reduce((prev: ValidateValue, next: Form) => {
+    prev[next.key] = next.value
+    return prev
+  }, {})
+  const [value, setValue] = useState(prevValue)
+  const [validate, validating, validatedStatusState] = useValidate(value)
+
+  eventEmitter?.useSubscription((v) => {
+    if (v !== triggerKey) {
+      setIsOpen(false)
+      setValue(prevValue)
+      validate({ before: () => false })
+    }
+  })
+
+  const handleCancel = () => {
+    eventEmitter?.emit('')
+  }
+
+  const handleSave = async () => {
+    if (await onSave(value))
+      eventEmitter?.emit('')
+  }
+
+  const handleAdd = () => {
+    setIsOpen(true)
+    eventEmitter?.emit(triggerKey)
+  }
+
+  const handleEdit = () => {
+    setIsOpen(true)
+    eventEmitter?.emit(triggerKey)
+  }
+
+  const handleChange = (form: Form, val: string) => {
+    setValue({ ...value, [form.key]: val })
+
+    if (form.validate)
+      validate(form.validate)
+  }
+
+  const handleFocus = (form: Form) => {
+    if (form.handleFocus)
+      form.handleFocus(value, setValue)
+  }
+
+  return (
+    <div className='mb-2 border-[0.5px] border-gray-200 bg-gray-50 rounded-md'>
+      <div className={
+        `flex items-center justify-between px-4 h-[52px] cursor-pointer ${isOpen && 'border-b-[0.5px] border-b-gray-200'}`
+      }>
+        {title}
+        <Operate
+          isOpen={isOpen}
+          status={status}
+          onCancel={handleCancel}
+          onSave={handleSave}
+          onAdd={handleAdd}
+          onEdit={handleEdit}
+        />
+      </div>
+      {
+        isOpen && (
+          <div className='px-4 py-3'>
+            {
+              forms.map(form => (
+                <KeyInput
+                  key={form.key}
+                  className='mb-4'
+                  name={form.title}
+                  placeholder={form.placeholder}
+                  value={value[form.key] || ''}
+                  onChange={v => handleChange(form, v)}
+                  onFocus={() => handleFocus(form)}
+                  validating={validating}
+                  validatedStatusState={validatedStatusState}
+                />
+              ))
+            }
+            <a className="flex items-center text-xs cursor-pointer text-primary-600" href={keyFrom.link} target={'_blank'}>
+              {keyFrom.text}
+              <LinkExternal02 className='w-3 h-3 ml-1 text-primary-600' />
+            </a>
+          </div>
+        )
+      }
+    </div>
+  )
+}
+
+export default KeyValidator

+ 77 - 0
web/app/components/header/account-setting/plugin-page/SerpapiPlugin.tsx

@@ -0,0 +1,77 @@
+import { useTranslation } from 'react-i18next'
+import Image from 'next/image'
+import SerpapiLogo from '../../assets/serpapi.png'
+import KeyValidator from '../key-validator'
+import type { Form, ValidateValue } from '../key-validator/declarations'
+import { updatePluginKey, validatePluginKey } from './utils'
+import { useToastContext } from '@/app/components/base/toast'
+import type { PluginProvider } from '@/models/common'
+
+type SerpapiPluginProps = {
+  plugin: PluginProvider
+  onUpdate: () => void
+}
+const SerpapiPlugin = ({
+  plugin,
+  onUpdate,
+}: SerpapiPluginProps) => {
+  const { t } = useTranslation()
+  const { notify } = useToastContext()
+
+  const forms: Form[] = [{
+    key: 'api_key',
+    title: t('common.plugin.serpapi.apiKey'),
+    placeholder: t('common.plugin.serpapi.apiKeyPlaceholder'),
+    value: plugin.credentials?.api_key,
+    validate: {
+      before: (v) => {
+        if (v?.api_key)
+          return true
+      },
+      run: async (v) => {
+        return validatePluginKey('serpapi', {
+          credentials: {
+            api_key: v?.api_key,
+          },
+        })
+      },
+    },
+    handleFocus: (v, dispatch) => {
+      if (v.api_key === plugin.credentials?.api_key)
+        dispatch({ ...v, api_key: '' })
+    },
+  }]
+
+  const handleSave = async (v: ValidateValue) => {
+    if (!v?.api_key || v?.api_key === plugin.credentials?.api_key)
+      return
+
+    const res = await updatePluginKey('serpapi', {
+      credentials: {
+        api_key: v?.api_key,
+      },
+    })
+
+    if (res.status === 'success') {
+      notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
+      onUpdate()
+      return true
+    }
+  }
+
+  return (
+    <KeyValidator
+      type='serpapi'
+      title={<Image alt='serpapi logo' src={SerpapiLogo} width={64} />}
+      status={plugin.credentials?.api_key ? 'success' : 'add'}
+      forms={forms}
+      keyFrom={{
+        text: t('common.plugin.serpapi.keyFrom'),
+        link: 'https://serpapi.com/manage-api-key',
+      }}
+      onSave={handleSave}
+    />
+  )
+}
+
+export default SerpapiPlugin

+ 38 - 0
web/app/components/header/account-setting/plugin-page/index.tsx

@@ -0,0 +1,38 @@
+import useSWR from 'swr'
+import { LockClosedIcon } from '@heroicons/react/24/solid'
+import { useTranslation } from 'react-i18next'
+import Link from 'next/link'
+import SerpapiPlugin from './SerpapiPlugin'
+import { fetchPluginProviders } from '@/service/common'
+import type { PluginProvider } from '@/models/common'
+
+const PluginPage = () => {
+  const { t } = useTranslation()
+  const { data: plugins, mutate } = useSWR('/workspaces/current/tool-providers', fetchPluginProviders)
+
+  const Plugin_MAP: Record<string, any> = {
+    serpapi: (plugin: PluginProvider) => <SerpapiPlugin key='serpapi' plugin={plugin} onUpdate={() => mutate()} />,
+  }
+
+  return (
+    <div className='pb-7'>
+      <div>
+        {plugins?.map(plugin => Plugin_MAP[plugin.tool_name](plugin))}
+      </div>
+      <div className='fixed bottom-0 w-[472px] h-[42px] flex items-center bg-white text-xs text-gray-500'>
+        <LockClosedIcon className='w-3 h-3 mr-1' />
+        {t('common.provider.encrypted.front')}
+        <Link
+          className='text-primary-600 mx-1'
+          target={'_blank'}
+          href='https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html'
+        >
+          PKCS1_OAEP
+        </Link>
+        {t('common.provider.encrypted.back')}
+      </div>
+    </div>
+  )
+}
+
+export default PluginPage

+ 0 - 0
web/app/components/header/account-setting/plugin-page/utils.ts


Some files were not shown because too many files changed in this diff